---
title: "코딩 에이전트의 할 일을 애플 미리알림에 (2) — 구조와 세팅"
description: "코딩 에이전트의 작업 과정을 애플 미리알림과 동기화하는 구조와 세팅을 상세히 소개합니다. 메모 필드를 활용한 고유 키 매칭과 상태 기반의 효율적인 업데이트, 정상 및 비정상 종료를 고려한 자동 정리 전략을 통해 에이전트의 진행 상황을 아이폰에서 실시간으로 확인하는 최적의 환경을 구축하는 방법을 다룹니다"
date: 2026-09-01
updated: 2026-09-01T07:30:18.053Z
tags: [applescript, macos, claude-code, automation, hooks]
canonical: https://blog.wooncloud.com/posts/claude-code-todo-apple-reminders-2
---

![구조와 세팅](/images/posts/claude-code-todo-apple-reminders-2/e44c6128-9588-47b6-9e16-54f2e5a83a2c.webp)

[앞 글](/posts/claude-code-todo-apple-reminders-1)에서 애플 미리알림을 스크립트로 어디까지 다룰 수 있는지, 그리고 쓰기 한 번이 5초씩 걸리던 원인을 정리했다. 이번엔 그걸 실제 하네스로 조립한다. 코딩 에이전트가 여러 단계짜리 작업을 시작하면 할 일이 미리알림에 뜨고, 진행하면서 체크되고, 세션이 끝나면 알아서 사라지는 구조다.

최종적으로 이렇게 보인다. 세션 두 개가 동시에 돌고 있는 상태다.

```text
Claude Code                     (리스트 하나를 공유)
──────────────────────────────────────────────────
⚑ ▶ [myapp·a1b2] 검색 쿼리 인덱스 추가
     [myapp·a1b2] 테스트 작성
⚑ ▶ [worker·7f3c] 알림 배치 수정
     [worker·7f3c] PR 올리기
  ✓  [myapp·a1b2] 스키마 확인
```

`▶` 와 깃발(`⚑`)이 지금 붙잡고 있는 작업이다. 사이드바의 "깃발 표시"를 누르면 리스트를 가로질러 **모든 세션의 현재 작업만** 모인다.

## 구조

세 갈래 입력이 하나의 스크립트로 모이고, 스크립트는 직전 상태와 비교해 바뀐 것만 미리알림에 쓴다.

![동기화 하네스 구조](/images/posts/claude-code-todo-apple-reminders-2/82161084-882a-44be-8570-3e7ce38466f2.webp)

- **CLI** — 에이전트가 직접 부른다. `TodoWrite` 같은 내장 할 일 도구를 쓰지 않는 세션에서도 동작하게 하는 주 경로다.
- **PostToolUse 훅** — 내장 할 일 도구를 쓰는 세션은 훅이 자동으로 같은 동기화를 태운다.
- **SessionStart / SessionEnd 훅** — 정리 담당. 뒤에서 따로 다룬다.

## 설계에서 정한 것 세 가지

### 리스트는 하나, 출처는 제목 접두어

세션마다 리스트를 따로 만드는 안과 리스트 하나를 공유하는 안을 두고 고민했다. 리스트를 나누면 사이드바에서 완전히 갈리지만, 공들여 꾸며둔 리스트(색상·아이콘)를 못 쓰고 리스트가 계속 늘어난다.

리스트 하나를 쓰기로 하고, 출처는 제목 접두어 `[프로젝트·세션4자]` 로 밝혔다. 태그를 쓸 수 있었다면 그쪽이 깔끔했겠지만 앞 글에서 본 대로 태그는 제어가 안 된다.

### 소유권은 제목이 아니라 메모에 심는다

제목은 상태에 따라 바뀐다(진행 중이면 `▶` 가 붙는다). 그래서 제목으로 항목을 찾으면 상태가 바뀌는 순간 매칭이 깨진다.

항목의 **메모(body)** 에 `<세션id>#<번호>` 를 심어 이걸 키로 썼다. 사용자에게는 거의 보이지 않고, 세션마다 고유하며, 상태가 바뀌어도 불변이다.

```applescript
first reminder of list "Claude Code" whose body is "a1b2c3d4#2"
```

이 덕분에 여러 세션이 한 리스트를 공유해도 서로의 항목을 건드릴 일이 없다. 각 세션은 자기 접두사로 시작하는 키만 조회한다.

### 바뀐 것만 쓴다

미리알림 쓰기는 비싸다. 그래서 직전 상태를 파일에 저장해두고 목표 상태와 비교해, **실제로 달라진 항목만** AppleScript 로 만든다. 항목 다섯 개짜리 목록에서 하나를 완료 처리하면 문장 하나만 나간다.

```json
{
  "proj": "myapp",
  "items": [
    { "content": "검색 쿼리 인덱스 추가", "status": "in_progress" },
    { "content": "테스트 작성", "status": "pending" }
  ]
}
```

이 파일은 `~/.claude/harness/todo-state/<세션id>.json` 에 둔다. 나중에 정리(prune)의 기준으로도 쓰인다.

## 스크립트 전문

`~/.claude/harness/todo-reminders.mjs` 로 저장한다. 의존성은 없고 Node 로 바로 실행된다.

```javascript
#!/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` 에 훅 셋을 등록한다. 이미 다른 훅이 있다면 배열에 항목을 더하면 된다.

```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 을 파싱해 항목만 더하는 편이 안전하다.

```bash
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 를 부른다.

```markdown
# 작업 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시간을 넘은 세션을 죽은 것으로 보고 지운다.

여기서 놓치기 쉬운 게 있다. **살아 있는 자기 세션은 건너뛰어야 한다.**

```javascript
if (s2 === sid) continue; // 살아있는 내 세션은 건드리지 않는다
```

이 한 줄이 없으면, 오래 켜둔 세션이 새 세션을 띄우는 순간 자기 항목을 스스로 지울 수 있다.

## 검증

훅과 규칙이 실제로 도는지는 헤드리스 세션을 하나 띄워보면 끝난다. 여기서 확인하고 싶었던 건 두 가지였다. 새 세션이 자기 세션 id 를 갖는가(부모 환경변수를 물려받으면 남의 항목을 건드린다), 그리고 종료 시 자기 것만 지우는가.

```bash
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 을 과거로 조작해서 확인할 수 있다.

```bash
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 기준으로.

에이전트가 지금 몇 번째 단계에 있는지 아이폰에서도 보인다는 게 생각보다 만족스럽다.
