코딩 에이전트의 할 일을 애플 미리알림에 (2) — 구조와 세팅
코딩 에이전트의 작업 과정을 애플 미리알림과 동기화하는 구조와 세팅을 상세히 소개합니다. 메모 필드를 활용한 고유 키 매칭과 상태 기반의 효율적인 업데이트, 정상 및 비정상 종료를 고려한 자동 정리 전략을 통해 에이전트의 진행 상황을 아이폰에서 실시간으로 확인하는 최적의 환경을 구축하는 방법을 다룹니다

앞 글에서 애플 미리알림을 스크립트로 어디까지 다룰 수 있는지, 그리고 쓰기 한 번이 5초씩 걸리던 원인을 정리했다. 이번엔 그걸 실제 하네스로 조립한다. 코딩 에이전트가 여러 단계짜리 작업을 시작하면 할 일이 미리알림에 뜨고, 진행하면서 체크되고, 세션이 끝나면 알아서 사라지는 구조다.
최종적으로 이렇게 보인다. 세션 두 개가 동시에 돌고 있는 상태다.
Claude Code (리스트 하나를 공유)
──────────────────────────────────────────────────
⚑ ▶ [myapp·a1b2] 검색 쿼리 인덱스 추가
[myapp·a1b2] 테스트 작성
⚑ ▶ [worker·7f3c] 알림 배치 수정
[worker·7f3c] PR 올리기
✓ [myapp·a1b2] 스키마 확인▶ 와 깃발(⚑)이 지금 붙잡고 있는 작업이다. 사이드바의 "깃발 표시"를 누르면 리스트를 가로질러 모든 세션의 현재 작업만 모인다.
구조
세 갈래 입력이 하나의 스크립트로 모이고, 스크립트는 직전 상태와 비교해 바뀐 것만 미리알림에 쓴다.

- CLI — 에이전트가 직접 부른다.
TodoWrite같은 내장 할 일 도구를 쓰지 않는 세션에서도 동작하게 하는 주 경로다. - PostToolUse 훅 — 내장 할 일 도구를 쓰는 세션은 훅이 자동으로 같은 동기화를 태운다.
- SessionStart / SessionEnd 훅 — 정리 담당. 뒤에서 따로 다룬다.
설계에서 정한 것 세 가지
리스트는 하나, 출처는 제목 접두어
세션마다 리스트를 따로 만드는 안과 리스트 하나를 공유하는 안을 두고 고민했다. 리스트를 나누면 사이드바에서 완전히 갈리지만, 공들여 꾸며둔 리스트(색상·아이콘)를 못 쓰고 리스트가 계속 늘어난다.
리스트 하나를 쓰기로 하고, 출처는 제목 접두어 [프로젝트·세션4자] 로 밝혔다. 태그를 쓸 수 있었다면 그쪽이 깔끔했겠지만 앞 글에서 본 대로 태그는 제어가 안 된다.
소유권은 제목이 아니라 메모에 심는다
제목은 상태에 따라 바뀐다(진행 중이면 ▶ 가 붙는다). 그래서 제목으로 항목을 찾으면 상태가 바뀌는 순간 매칭이 깨진다.
항목의 메모(body) 에 <세션id>#<번호> 를 심어 이걸 키로 썼다. 사용자에게는 거의 보이지 않고, 세션마다 고유하며, 상태가 바뀌어도 불변이다.
first reminder of list "Claude Code" whose body is "a1b2c3d4#2"이 덕분에 여러 세션이 한 리스트를 공유해도 서로의 항목을 건드릴 일이 없다. 각 세션은 자기 접두사로 시작하는 키만 조회한다.
바뀐 것만 쓴다
미리알림 쓰기는 비싸다. 그래서 직전 상태를 파일에 저장해두고 목표 상태와 비교해, 실제로 달라진 항목만 AppleScript 로 만든다. 항목 다섯 개짜리 목록에서 하나를 완료 처리하면 문장 하나만 나간다.
{
"proj": "myapp",
"items": [
{ "content": "검색 쿼리 인덱스 추가", "status": "in_progress" },
{ "content": "테스트 작성", "status": "pending" }
]
}이 파일은 ~/.claude/harness/todo-state/<세션id>.json 에 둔다. 나중에 정리(prune)의 기준으로도 쓰인다.
스크립트 전문
~/.claude/harness/todo-reminders.mjs 로 저장한다. 의존성은 없고 Node 로 바로 실행된다.
#!/usr/bin/env node
// Claude Code todo -> Apple 미리알림 동기화.
// 진입 둘: PostToolUse(TodoWrite) 훅(stdin JSON) 과 CLI(set/doing/done/show/clear/prune/session-end).
// 직전 상태와의 diff 만 쓰고, 항목 참조는 변수에 담지 않는다(담으면 쓰기당 5초).
import { execFile, execFileSync } from "node:child_process";
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join, basename } from "node:path";
const LIST = process.env.CC_TODO_LIST || "Claude Code";
const COLOR = "#FF9500";
const STATE_DIR = join(homedir(), ".claude", "harness", "todo-state");
const esc = (s) => String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const q = (s) => `"${esc(s)}"`;
const readStdin = () =>
new Promise((res) => {
if (process.stdin.isTTY) return res("");
let b = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (c) => (b += c));
process.stdin.on("end", () => res(b));
setTimeout(() => res(b), 2000);
});
function load(sid) {
try {
return JSON.parse(readFileSync(join(STATE_DIR, `${sid}.json`), "utf8"));
} catch {
return { proj: "", items: [] };
}
}
function save(sid, st) {
mkdirSync(STATE_DIR, { recursive: true });
writeFileSync(join(STATE_DIR, `${sid}.json`), JSON.stringify(st));
}
const title = (label, it) =>
(it.status === "in_progress" ? "▶ " : "") + `[${label}] ` + it.content;
// 직전 상태(prev)와 목표 상태(next)의 차이만 AppleScript 로 만든다.
function buildOps(sid, proj, prev, next) {
// 한 리스트를 여러 세션이 공유하므로 제목 접두어로 출처를 밝힌다 (태그는 AppleScript 로 제어 불가)
const label = `${proj}·${sid.slice(0, 4)}`;
const ops = [
`if not (exists list ${q(LIST)}) then make new list with properties {name:${q(LIST)}}`,
`set color of list ${q(LIST)} to ${q(COLOR)}`,
`set L to list ${q(LIST)}`,
];
let changed = false;
for (let i = next.length; i < prev.length; i++) {
ops.push(`try`, `delete (first reminder of L whose body is ${q(`${sid}#${i}`)})`, `end try`);
changed = true;
}
next.forEach((it, i) => {
const key = `${sid}#${i}`;
const t = title(label, it);
const done = it.status === "completed";
const flag = it.status === "in_progress"; // 깃발 = 진행 중 (사이드바 "깃발 표시" 가 전 세션 진행 열이 된다)
const before = prev[i];
if (!before) {
ops.push(
`make new reminder at end of L with properties {name:${q(t)}, body:${q(key)}, completed:${done}, flagged:${flag}}`,
);
changed = true;
return;
}
const bt = title(label, before);
const bdone = before.status === "completed";
const bflag = before.status === "in_progress";
const props = [];
if (bt !== t) props.push(`name:${q(t)}`);
if (bdone !== done) props.push(`completed:${done}`);
if (bflag !== flag) props.push(`flagged:${flag}`);
if (props.length === 0) return;
// ★ 항목 참조를 변수에 담아 속성을 하나씩 쓰면 쓰기당 5초가 붙는다 — 반드시 한 문장으로
ops.push(
`try`,
`set properties of (first reminder of L whose body is ${q(key)}) to {${props.join(", ")}}`,
`end try`,
);
changed = true;
});
return changed ? ops : null;
}
function apply(sid, proj, prev, next, { wait = false } = {}) {
const ops = buildOps(sid, proj, prev, next);
save(sid, { proj, items: next });
if (!ops) return "변경 없음";
const script = [`tell application "Reminders"`, ...ops.map((l) => " " + l), `end tell`].join("\n");
if (process.env.CC_TODO_DRYRUN) return script;
if (wait) {
execFileSync("/usr/bin/osascript", ["-e", script], { timeout: 180000 });
return "동기화 완료";
}
execFile("/usr/bin/osascript", ["-e", script], { timeout: 180000 }).unref();
return "동기화 시작";
}
// 한 세션이 남긴 미리알림 항목을 통째로 지운다 (세션 종료·고아 정리 공용)
function clearSession(sid) {
const st = load(sid);
const n = (st.items ?? []).length;
if (n > 0) {
const script = `tell application "Reminders"
repeat with i from ${n - 1} to 0 by -1
try
delete (first reminder of list ${q(LIST)} whose body is ("${esc(sid)}#" & i))
end try
end repeat
end tell`;
try {
execFileSync("/usr/bin/osascript", ["-e", script], { timeout: 180000 });
} catch {}
}
try {
unlinkSync(join(STATE_DIR, `${sid}.json`));
} catch {}
return n;
}
const argv = process.argv.slice(2);
const envSid = String(process.env.CLAUDE_CODE_SESSION_ID || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 8);
if (argv.length > 0) {
const [cmd, ...rest] = argv;
const sid = envSid || "cli";
const st = load(sid);
const proj = st.proj || basename(process.cwd());
const items = st.items ?? [];
if (cmd === "set") {
const next = rest.map((c) => ({ content: c, status: "pending" }));
console.log(apply(sid, proj, items, next, { wait: true }));
} else if (cmd === "doing" || cmd === "done") {
const n = Number(rest[0]);
if (!Number.isInteger(n) || n < 1 || n > items.length) {
console.error(`범위 밖: 1~${items.length}`);
process.exit(1);
}
const next = items.map((it, i) => ({
...it,
status:
i === n - 1
? cmd === "done"
? "completed"
: "in_progress"
: it.status === "in_progress" && cmd === "doing"
? "pending"
: it.status,
}));
console.log(apply(sid, proj, items, next, { wait: true }));
} else if (cmd === "show") {
items.forEach((it, i) =>
console.log(
`${i + 1}. [${it.status === "completed" ? "x" : it.status === "in_progress" ? "▶" : " "}] ${it.content}`,
),
);
} else if (cmd === "clear") {
console.log(`정리 완료 (${clearSession(sid)}건)`);
} else if (cmd === "session-end") {
// SessionEnd 훅: 세션 id 는 stdin payload 가 권위 (env 는 폴백)
const raw = await readStdin();
let target = sid;
try {
const j = JSON.parse(raw);
if (j?.session_id) target = String(j.session_id).replace(/[^a-zA-Z0-9]/g, "").slice(0, 8);
} catch {}
clearSession(target);
} else if (cmd === "prune") {
// 비정상 종료로 SessionEnd 를 못 탄 고아 항목 정리. 기준은 상태 파일 mtime.
const hours = Number(rest[0] ?? 12);
const cutoff = Date.now() - hours * 3600 * 1000;
let files = [];
try {
files = readdirSync(STATE_DIR).filter((f) => f.endsWith(".json"));
} catch {}
let n = 0;
for (const f of files) {
const s2 = f.slice(0, -5);
if (s2 === sid) continue; // 살아있는 내 세션은 건드리지 않는다
try {
if (statSync(join(STATE_DIR, f)).mtimeMs >= cutoff) continue;
} catch {
continue;
}
clearSession(s2);
n++;
}
console.log(`고아 세션 ${n}개 정리 (${hours}시간 초과)`);
} else {
console.error(
"usage: todo-reminders.mjs set <항목>... | doing <n> | done <n> | show | clear | prune [시간] | session-end",
);
process.exit(1);
}
process.exit(0);
}
// 훅 모드
const raw = await readStdin();
let p;
try {
p = JSON.parse(raw);
} catch {
process.exit(0);
}
const todos = p?.tool_input?.todos;
if (!Array.isArray(todos)) process.exit(0);
const sid = String(p.session_id ?? envSid ?? "hook").replace(/[^a-zA-Z0-9]/g, "").slice(0, 8);
const proj = String(p.cwd ?? "").split("/").filter(Boolean).pop() || "claude";
const st = load(sid);
apply(
sid,
proj,
st.items ?? [],
todos.map((t) => ({ content: String(t.content ?? "").trim(), status: t.status })),
);
process.exit(0);훅 배선
~/.claude/settings.json 에 훅 셋을 등록한다. 이미 다른 훅이 있다면 배열에 항목을 더하면 된다.
{
"hooks": {
"PostToolUse": [
{
"matcher": "TodoWrite",
"hooks": [
{
"type": "command",
"command": "node /Users/you/.claude/harness/todo-reminders.mjs",
"timeout": 15
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node /Users/you/.claude/harness/todo-reminders.mjs prune",
"timeout": 20
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "node /Users/you/.claude/harness/todo-reminders.mjs session-end",
"timeout": 20
}
]
}
]
}
}훅 커맨드에는 ~ 가 아니라 절대 경로를 쓴다.
settings.json 은 에이전트 본체가 쓰는 파일이라 통째로 다시 쓰면 위험하다. 손댈 때는 백업을 뜨고 JSON 을 파싱해 항목만 더하는 편이 안전하다.
cp ~/.claude/settings.json ~/.claude/settings.json.bak
python3 - <<'PY'
import json
p = "/Users/you/.claude/settings.json"
d = json.load(open(p, encoding="utf-8"))
hooks = d.setdefault("hooks", {}).setdefault("PostToolUse", [])
hooks[:] = [h for h in hooks if h.get("matcher") != "TodoWrite"] # 멱등하게
hooks.insert(0, {
"matcher": "TodoWrite",
"hooks": [{"type": "command",
"command": "node /Users/you/.claude/harness/todo-reminders.mjs",
"timeout": 15}],
})
json.dump(d, open(p, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
PY에이전트에게 쓰게 만들기
여기가 실질적으로 제일 중요한 부분이다. 훅만 걸어두면 내장 할 일 도구를 쓰는 세션에서만 동작한다. 내 환경에서는 그 도구가 거의 호출되지 않아서(전체 대화 기록을 통틀어 한 건), 훅만 믿었다면 미리알림은 계속 비어 있었을 것이다.
그래서 전역 ~/.claude/CLAUDE.md 에 사용 규칙을 넣었다. 모든 세션이 이 파일을 읽으므로, 에이전트가 스스로 CLI 를 부른다.
# 작업 todo 는 애플 미리알림에
**여러 단계짜리 작업은 미리알림 `Claude Code` 리스트에 올리고, 진행하면서 체크한다.**
사용자가 진행 상황을 실시간으로 보기 위한 장치이므로 끝나고 몰아서가 아니라
진행 도중에 갱신한다. 도구 = `node ~/.claude/harness/todo-reminders.mjs`.
- `set "항목1" "항목2" ...` 목록 등록 · `doing <n>` 진행 중(깃발+▶, 한 번에 하나만)
· `done <n>` 완료 · `show` 현재 상태 · `clear` 내 세션 항목 정리
- **발동 기준은 3단계 이상이거나 몇 분 넘게 걸릴 작업.** 단답·단건 수정에는 쓰지 않는다.
내장 할 일 도구를 쓰는 세션은 훅이 자동으로 태우므로 중복 호출하지 않는다
- 여러 세션이 리스트 하나를 공유하고 제목 접두어로 갈린다. 소유자는 항목 메모의
`<sid>#<n>` 이니 **남의 세션 항목을 손대지 않는다**
- 사이드바 "깃발 표시" = 전 세션의 현재 작업 뷰. 그래서 `doing` 은 지금 붙잡은 하나에만 건다발동 기준을 명시한 게 중요하다. 이걸 빼면 한 줄짜리 수정에도 목록을 만들어 리스트가 금방 지저분해진다.
정리 전략
항목을 계속 쌓아두면 리스트가 못 쓰게 된다. 두 단계로 회수한다.
정상 종료 — SessionEnd 훅이 그 세션 항목을 통째로 지운다. 세션 id 는 훅이 stdin 으로 주는 값을 쓴다(환경변수는 폴백). 미리알림은 "지금 뭘 하고 있나"를 보는 창이고, 기록은 커밋과 대화에 남는다는 판단이다.
비정상 종료 — 크래시나 강제 종료로 SessionEnd 를 못 타면 항목이 남는다. 이건 SessionStart 의 prune 이 회수한다. 상태 파일의 mtime 이 12시간을 넘은 세션을 죽은 것으로 보고 지운다.
여기서 놓치기 쉬운 게 있다. 살아 있는 자기 세션은 건너뛰어야 한다.
if (s2 === sid) continue; // 살아있는 내 세션은 건드리지 않는다이 한 줄이 없으면, 오래 켜둔 세션이 새 세션을 띄우는 순간 자기 항목을 스스로 지울 수 있다.
검증
훅과 규칙이 실제로 도는지는 헤드리스 세션을 하나 띄워보면 끝난다. 여기서 확인하고 싶었던 건 두 가지였다. 새 세션이 자기 세션 id 를 갖는가(부모 환경변수를 물려받으면 남의 항목을 건드린다), 그리고 종료 시 자기 것만 지우는가.
env -u CLAUDE_CODE_SESSION_ID claude -p \
'지금 세션의 CLAUDE_CODE_SESSION_ID 를 확인하고, todo-reminders CLI 로
"검증항목 A" "검증항목 B" "검증항목 C" 를 등록한 뒤 1번을 doing, 2번을 done 으로 바꿔라.'결과는 이랬다.
- 자식 세션이 자기 session_id 를 가졌다. 부모 환경변수를 물려받지 않는다.
- 규칙을 읽고 CLI 를 찾아 썼고, 항목 3개가 자기 접두어로 등록됐다.
- 세션이 끝나자 그 3개가 자동으로 사라졌고, 부모 세션 항목은 그대로 남았다.
고아 정리는 상태 파일의 mtime 을 과거로 조작해서 확인할 수 있다.
touch -t $(date -v-24H +%Y%m%d%H%M) ~/.claude/harness/todo-state/deadbeef.json
node ~/.claude/harness/todo-reminders.mjs prune
# 고아 세션 1개 정리 (12시간 초과)마무리
미리알림 쪽 제약(섹션·태그 불가)과 성능 함정(참조 변수 5초)만 피하면, 나머지는 평범한 diff 동기화다. 만들면서 도움이 됐던 판단을 꼽자면 이 셋이다.
- 매칭 키를 사용자에게 보이는 필드에 두지 않는다. 제목은 상태에 따라 변하니 메모에 심었다.
- 훅과 규칙 둘 다 건다. 훅은 특정 도구를 쓸 때만 돌고, 규칙은 그 도구를 안 쓰는 세션까지 커버한다.
- 정리를 두 겹으로 둔다. 정상 종료는
SessionEnd, 비정상 종료는 다음 세션 시작 시 mtime 기준으로.
에이전트가 지금 몇 번째 단계에 있는지 아이폰에서도 보인다는 게 생각보다 만족스럽다.