granttap-mcp 0.8.4 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -87,7 +87,9 @@ export function buildTaskCapsule(
87
87
  };
88
88
  // A checkpoint commit is the fact the capsule carries instead of the dirty
89
89
  // tree: it holds every change the tree had, so relative to it the tree is
90
- // clean. Whether the destination has that commit is its own check.
90
+ // clean. Whether the destination has that commit is its own check. What
91
+ // the commit does not hold is said too, so the destination never takes a
92
+ // partial checkpoint for the whole of the work.
91
93
  const described = checkpoint
92
94
  ? {
93
95
  ...capsule,
@@ -99,13 +101,38 @@ export function buildTaskCapsule(
99
101
  remainingWork: [
100
102
  `Continue from checkpoint ${checkpoint.sha.slice(0, 12)} on ${checkpoint.branch}.`,
101
103
  `Push ${checkpoint.branch} from ${computerId} if the destination does not have it.`,
104
+ ...checkpointRemainingWork(checkpoint, computerId),
102
105
  ],
106
+ importantDecisions: checkpointDecisions(checkpoint),
107
+ checkpoint: {
108
+ status: checkpoint.status,
109
+ files: checkpoint.files,
110
+ excluded: checkpoint.excluded.slice(0, 32),
111
+ },
103
112
  }
104
113
  : capsule;
105
114
  const parsed = TaskCapsule.safeParse(described);
106
115
  return parsed.success ? parsed.data : undefined;
107
116
  }
108
117
 
118
+ function checkpointRemainingWork(checkpoint: Checkpoint, computerId: string): string[] {
119
+ if (checkpoint.excluded.length === 0) return [];
120
+ return [
121
+ `Left on ${computerId}, not in the checkpoint: ${checkpoint.excluded.slice(0, 8).join(", ")}`
122
+ + `${checkpoint.excluded.length > 8 ? ` (+${checkpoint.excluded.length - 8})` : ""}. `
123
+ + "They look like secrets; recreate them there if the work needs them.",
124
+ ].map((line) => line.slice(0, 1_000));
125
+ }
126
+
127
+ function checkpointDecisions(checkpoint: Checkpoint): string[] {
128
+ switch (checkpoint.status) {
129
+ case "complete": return [];
130
+ case "partial": return ["Checkpoint is partial: files that look like secrets stayed on the source computer."];
131
+ case "requires_review":
132
+ return ["Checkpoint needs review: the checkout was shared with other work, so the commit may carry changes that are not this Task's."];
133
+ }
134
+ }
135
+
109
136
  function changedFilesBetween(cwd: string, from: string, to: string): string[] {
110
137
  const listed = git(cwd, ["diff", "--name-only", `${from}..${to}`]) ?? "";
111
138
  return [...new Set(listed.split("\n").map((line) => line.trim()).filter(Boolean))].slice(0, 64);
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { sensitivePath } from "../sessions/edit-stats";
5
+ import { secretFilePath } from "../sessions/edit-stats";
6
6
 
7
7
  /**
8
8
  * Commit everything uncommitted to a checkpoint branch, touching nothing else.
@@ -14,12 +14,21 @@ import { sensitivePath } from "../sessions/edit-stats";
14
14
  * publishing a branch is the person's decision, and the destination says so
15
15
  * if the commit has not reached it yet.
16
16
  */
17
+ export type CheckpointStatus = "complete" | "partial" | "requires_review";
18
+
17
19
  export type Checkpoint = {
18
20
  sha: string;
19
21
  branch: string;
20
22
  files: number;
21
- /** Paths left out because they look like secrets; never part of a checkpoint. */
23
+ /** Paths left out because they are secrets by name; never part of a checkpoint. */
22
24
  excluded: string[];
25
+ /**
26
+ * complete: every uncommitted change is in the commit. partial: secrets
27
+ * stayed behind, named in `excluded`. requires_review: the checkout was
28
+ * shared with other work, so the commit may carry changes that are not
29
+ * this Task's; the person decides.
30
+ */
31
+ status: CheckpointStatus;
23
32
  };
24
33
 
25
34
  export const CHECKPOINT_BRANCH_PREFIX = "granttap/checkpoint/";
@@ -34,13 +43,16 @@ function git(cwd: string, args: string[], env: NodeJS.ProcessEnv = process.env):
34
43
  /**
35
44
  * One branch per checkpoint, not per Task. A Task handed off twice used to
36
45
  * force-move the same branch, and the first checkpoint's commit was left
37
- * unreachable; a stamp on the name keeps each one.
46
+ * unreachable. The name carries the moment to the millisecond: the same
47
+ * request tried again lands on the same branch, two requests never do.
38
48
  */
39
49
  export function checkpointBranchName(taskId: string, at?: number): string {
40
50
  const base = CHECKPOINT_BRANCH_PREFIX + taskId.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 64);
41
51
  if (at == null) return base;
42
- const stamp = new Date(at).toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "");
43
- return `${base}-${stamp}`;
52
+ const iso = new Date(at).toISOString();
53
+ const stamp = iso.slice(0, 19).replace(/[-:]/g, "");
54
+ const millis = iso.slice(20, 23);
55
+ return `${base}-${stamp}-${millis}`;
44
56
  }
45
57
 
46
58
  export function createCheckpoint(
@@ -58,9 +70,9 @@ export function createCheckpoint(
58
70
  git(cwd, ["add", "-A"], env);
59
71
  // A checkpoint is the whole checkout's uncommitted work, so an .env or a
60
72
  // key that happened to change in it would be committed and, when the
61
- // person pushes, published. Those stay as HEAD has them.
73
+ // person pushes, published. Those stay as HEAD has them, and are named.
62
74
  const excluded = git(cwd, ["diff", "--cached", "--name-only"], env)
63
- .split("\n").filter(Boolean).filter(sensitivePath);
75
+ .split("\n").filter(Boolean).filter(secretFilePath);
64
76
  if (excluded.length > 0) git(cwd, ["reset", "-q", "--", ...excluded], env);
65
77
  const tree = git(cwd, ["write-tree"], env);
66
78
  if (tree === git(cwd, ["rev-parse", "HEAD^{tree}"])) return undefined; // nothing to keep
@@ -69,7 +81,7 @@ export function createCheckpoint(
69
81
  const branch = checkpointBranchName(taskId, at);
70
82
  git(cwd, ["branch", "-f", branch, sha]);
71
83
  const files = git(cwd, ["diff", "--name-only", `${head}..${sha}`]).split("\n").filter(Boolean).length;
72
- return { sha, branch, files, excluded };
84
+ return { sha, branch, files, excluded, status: excluded.length > 0 ? "partial" : "complete" };
73
85
  } catch {
74
86
  return undefined;
75
87
  } finally {
@@ -111,13 +111,20 @@ export function markRunsDelivered(sessionId: string, at: number, only?: readonly
111
111
  }
112
112
  }
113
113
 
114
+ /** How much of a run one line may carry: fewer files, a shorter outcome, when room is short. */
115
+ export type RunDescriptionLimits = { files?: number; outcome?: number };
116
+
114
117
  /** One line a person or a model can read: what was asked, what happened, what it touched. */
115
- export function describeRun(record: RunRecord): string {
116
- const parts = [`«${record.prompt}» ${record.ok ? "" : "failed: "}${record.outcome}`];
117
- if (record.files.length > 0) {
118
- const shown = record.files.slice(0, 6).join(", ");
119
- const more = record.files.length > 6 ? ` (+${record.files.length - 6})` : "";
118
+ export function describeRun(record: RunRecord, limits: RunDescriptionLimits = {}): string {
119
+ const outcome = limits.outcome == null ? record.outcome : compactText(record.outcome, limits.outcome);
120
+ const parts = [`«${record.prompt}» → ${record.ok ? "" : "failed: "}${outcome}`];
121
+ const files = limits.files ?? 6;
122
+ if (record.files.length > 0 && files > 0) {
123
+ const shown = record.files.slice(0, files).join(", ");
124
+ const more = record.files.length > files ? ` (+${record.files.length - files})` : "";
120
125
  parts.push(`wrote ${shown}${more}`);
126
+ } else if (record.files.length > 0) {
127
+ parts.push(`wrote ${record.files.length} file${record.files.length === 1 ? "" : "s"} (see the transcript)`);
121
128
  }
122
129
  if (record.tools > 0) parts.push(`${record.tools} tool call${record.tools === 1 ? "" : "s"}`);
123
130
  if (record.cutOff) {
@@ -7,17 +7,23 @@
7
7
  * `UserPromptSubmit` hook to add to the prompt — the unread journal first,
8
8
  * then the Mesh brief — so the model coordinates without being told to look.
9
9
  *
10
- * The text is bounded, and the bound is honoured by showing fewer runs, never
11
- * by cutting the text: a run is marked read only once it was actually shown,
12
- * so what did not fit this time comes on the next turn instead of vanishing.
10
+ * The text is bounded, and the bound is honoured by saying less, never by
11
+ * cutting: fewer runs, then fewer Mesh lines, then shorter run lines. The
12
+ * Project's name and the way to the full map are always kept. A run is
13
+ * marked read only once it was actually shown, whole or in its short form;
14
+ * what did not fit comes on the next turn instead of vanishing.
13
15
  */
14
16
  import { liveExecutionScope } from "./capability";
15
- import { describeRun, markRunsDelivered, unreadRuns, type RunRecord } from "./journal";
17
+ import {
18
+ describeRun, markRunsDelivered, unreadRuns, type RunDescriptionLimits, type RunRecord,
19
+ } from "./journal";
16
20
  import { meshBrief } from "./map";
17
21
  import type { MeshSnapshot } from "../../../../packages/protocol/schema";
18
22
 
19
23
  export const MAX_CONTEXT_CHARS = 2_400;
20
24
  const MAX_RUNS_SHOWN = 5;
25
+ /** Ever shorter ways to say one run, tried in order when room is short. */
26
+ const COMPACTIONS: RunDescriptionLimits[] = [{}, { files: 3, outcome: 300 }, { files: 0, outcome: 120 }];
21
27
 
22
28
  export type PromptContextDeps = {
23
29
  unread: (sessionId: string) => RunRecord[];
@@ -39,22 +45,30 @@ function clock(at: number): string {
39
45
  return new Date(at).toISOString().slice(11, 16);
40
46
  }
41
47
 
42
- function runLines(total: number, shown: RunRecord[]): string[] {
48
+ function runLines(total: number, shown: RunRecord[], limits: RunDescriptionLimits): string[] {
43
49
  if (shown.length === 0) return [];
44
50
  const lines = [
45
51
  `GrantTap: ${total} message${total === 1 ? "" : "s"} from the phone ${total === 1 ? "was" : "were"} `
46
52
  + "handled in this chat by background runs since your last turn. Their turns are in the transcript but not in your context:",
47
53
  ];
48
54
  shown.forEach((run, index) => {
49
- lines.push(`${index + 1}. [${clock(run.at)}] ${describeRun(run)}`);
55
+ lines.push(`${index + 1}. [${clock(run.at)}] ${describeRun(run, limits)}`);
50
56
  });
51
57
  if (total > shown.length) lines.push(`(+${total - shown.length} earlier; they follow on your next turn)`);
52
58
  lines.push("Continue from what they did; check the working tree before redoing or undoing it.");
53
59
  return lines;
54
60
  }
55
61
 
56
- function compose(runs: string[], brief: string[]): string {
57
- return [...runs, ...(runs.length > 0 && brief.length > 0 ? [""] : []), ...brief].join("\n");
62
+ /** The Mesh part of the prompt: the Project's name and the map are the envelope, the bullets can go. */
63
+ type Brief = { head: string; bullets: string[]; map: string };
64
+
65
+ function briefLines(brief: Brief | undefined): string[] {
66
+ return brief ? [brief.head, ...brief.bullets, brief.map] : [];
67
+ }
68
+
69
+ function compose(runs: string[], brief: Brief | undefined): string {
70
+ const mesh = briefLines(brief);
71
+ return [...runs, ...(runs.length > 0 && mesh.length > 0 ? [""] : []), ...mesh].join("\n");
58
72
  }
59
73
 
60
74
  /** The text to add to the next prompt of a chat, or nothing when there is nothing new. */
@@ -66,33 +80,44 @@ export function promptContext(
66
80
  if (!sessionId) return undefined;
67
81
  const runs = deps.unread(sessionId);
68
82
  const scope = deps.scope(sessionId);
69
- let brief: string[] = [];
83
+ let brief: Brief | undefined;
70
84
  if (scope) {
71
85
  const lines = meshBrief(scope.snapshot, scope.taskId, now);
72
86
  if (lines.length > 0) {
73
- brief = [
74
- `Project Mesh «${scope.snapshot.project.name}»:`,
75
- ...lines.map((line) => `- ${line}`),
87
+ brief = {
88
+ head: `Project Mesh «${scope.snapshot.project.name}»:`,
89
+ bullets: lines.map((line) => `- ${line}`),
76
90
  // Listed by name, not by token: Claude Code reads only listed resources,
77
91
  // and the server finds the chat from its own environment.
78
- "Full map: read the granttap MCP resource granttap://mesh/map",
79
- ];
92
+ map: "Full map: read the granttap MCP resource granttap://mesh/map",
93
+ };
80
94
  }
81
95
  }
82
- if (runs.length === 0 && brief.length === 0) return undefined;
83
- // The newest runs first, as many as fit; then the brief gives way, one line
84
- // at a time, because it is rebuilt from the Mesh on every turn anyway.
96
+ if (runs.length === 0 && !brief) return undefined;
85
97
  let shown = runs.slice(-MAX_RUNS_SHOWN);
86
- let text = compose(runLines(runs.length, shown), brief);
98
+ let limits = COMPACTIONS[0]!;
99
+ let text = compose(runLines(runs.length, shown, limits), brief);
100
+ // The newest runs first, as many as fit; then the Mesh bullets give way,
101
+ // because they are rebuilt from the Mesh on every turn; then each run is
102
+ // said more briefly. The envelope — the Project and the map — stays.
87
103
  while (text.length > MAX_CONTEXT_CHARS && shown.length > 1) {
88
104
  shown = shown.slice(1);
89
- text = compose(runLines(runs.length, shown), brief);
105
+ text = compose(runLines(runs.length, shown, limits), brief);
106
+ }
107
+ while (text.length > MAX_CONTEXT_CHARS && brief && brief.bullets.length > 0) {
108
+ brief = { ...brief, bullets: brief.bullets.slice(0, -1) };
109
+ text = compose(runLines(runs.length, shown, limits), brief);
90
110
  }
91
- while (text.length > MAX_CONTEXT_CHARS && brief.length > 2) {
92
- brief = [...brief.slice(0, -2), brief[brief.length - 1]!];
93
- text = compose(runLines(runs.length, shown), brief);
111
+ for (const compaction of COMPACTIONS.slice(1)) {
112
+ if (text.length <= MAX_CONTEXT_CHARS) break;
113
+ limits = compaction;
114
+ text = compose(runLines(runs.length, shown, limits), brief);
94
115
  }
95
- if (text.length > MAX_CONTEXT_CHARS) text = `${text.slice(0, MAX_CONTEXT_CHARS - 1)}…`;
96
- if (shown.length > 0) deps.markDelivered(sessionId, now, shown.map((run) => run.at));
116
+ // Bounded by construction; should a line still not fit, it is cut and the
117
+ // run it belongs to is left unread rather than marked as told.
118
+ const cut = text.length > MAX_CONTEXT_CHARS;
119
+ if (cut) text = `${text.slice(0, MAX_CONTEXT_CHARS - 1)}…`;
120
+ const told = cut ? shown.slice(0, -1) : shown;
121
+ if (told.length > 0) deps.markDelivered(sessionId, now, told.map((run) => run.at));
97
122
  return text;
98
123
  }
@@ -20,6 +20,15 @@ export function createHandoffFlow(deps: MeshRuntimeDependencies) {
20
20
  `Base SHA: ${capsule.baseSha}`,
21
21
  ];
22
22
  if (capsule.latestCommit) lines.push(`Latest commit: ${capsule.latestCommit}`);
23
+ if (capsule.checkpoint) {
24
+ const { status, files, excluded } = capsule.checkpoint;
25
+ const line = status === "complete"
26
+ ? `Checkpoint: complete (${files} file${files === 1 ? "" : "s"}).`
27
+ : status === "partial"
28
+ ? `Checkpoint: partial (${files} file${files === 1 ? "" : "s"}); left on the source computer as secrets: ${excluded.join(", ")}.`
29
+ : `Checkpoint: needs review (${files} file${files === 1 ? "" : "s"}); the checkout was shared with other work, so some changes may not be this task's.`;
30
+ lines.push(line);
31
+ }
23
32
  if (capsule.testsStatus) lines.push(`Tests: ${capsule.testsStatus}`);
24
33
  if (capsule.importantDecisions.length) {
25
34
  lines.push(`Important decisions:\n- ${capsule.importantDecisions.join("\n- ")}`);
@@ -110,10 +110,21 @@ export function createMeshRuntime(deps: MeshRuntimeDependencies) {
110
110
  }
111
111
  // Asked to checkpoint, uncommitted work is committed to a branch of its
112
112
  // own first, so the Task can leave without losing it. Nothing is pushed.
113
+ // The branch is named by the request's own moment, so the same request
114
+ // tried again lands on the same branch and two requests never do.
113
115
  const cwd = session.worktree ?? session.cwd;
114
- const checkpoint = request.checkpoint && cwd && workingTreeState(cwd) === "dirty"
115
- ? createCheckpoint(cwd, request.taskId, session.title ?? request.taskId, deps.now())
116
+ const made = request.checkpoint && cwd && workingTreeState(cwd) === "dirty"
117
+ ? createCheckpoint(cwd, request.taskId, session.title ?? request.taskId, request.createdAt)
116
118
  : undefined;
119
+ // A checkout other Tasks are working in at the same time holds their
120
+ // changes too; the commit cannot tell them apart, so it says so.
121
+ const shared = made && cwd
122
+ ? sessions.some((other) =>
123
+ other.sessionId !== session.sessionId
124
+ && other.taskId != null && other.taskId !== request.taskId
125
+ && (other.worktree ?? other.cwd) === cwd)
126
+ : false;
127
+ const checkpoint = made && shared ? { ...made, status: "requires_review" as const } : made;
117
128
  const capsule = buildTaskCapsule(deps.store(), session, request, deps.computer(), checkpoint);
118
129
  const readiness = handoffReadiness({
119
130
  capsule,
@@ -20,9 +20,25 @@ import {
20
20
  type TaskDependency as DependencyValue,
21
21
  } from "../../../../packages/protocol/schema";
22
22
  import { lstatSync, readFileSync } from "node:fs";
23
+ import { z } from "zod";
24
+ import { capsuleHash } from "./handoff";
23
25
 
24
26
  const MAX_STORE_BYTES = 4 * 1_024 * 1_024;
25
27
 
28
+ /**
29
+ * A capsule rewritten when two Tasks were rejoined, on record: its hash
30
+ * changed with its Task id, and a receipt that names the old hash is still a
31
+ * receipt for it. Kept locally only; nothing on the wire carries it.
32
+ */
33
+ export const CapsuleMigration = z.object({
34
+ at: z.number().nonnegative(),
35
+ taskIdFrom: z.string().min(1).max(128),
36
+ taskIdTo: z.string().min(1).max(128),
37
+ capsuleHashFrom: z.string().regex(/^[0-9a-f]{64}$/),
38
+ capsuleHashTo: z.string().regex(/^[0-9a-f]{64}$/),
39
+ }).strict();
40
+ export type CapsuleMigration = z.infer<typeof CapsuleMigration>;
41
+
26
42
  export type StoreState = {
27
43
  version: 1;
28
44
  projects: ProjectValue[];
@@ -34,14 +50,16 @@ export type StoreState = {
34
50
  dependencies: DependencyValue[];
35
51
  events: MeshEventValue[];
36
52
  receipts: ReceiptValue[];
53
+ migrations: CapsuleMigration[];
37
54
  };
38
55
 
39
56
  const EMPTY: StoreState = {
40
57
  version: 1, projects: [], bindings: [], peers: [], tasks: [], executions: [], claims: [],
41
- dependencies: [], events: [], receipts: [],
58
+ dependencies: [], events: [], receipts: [], migrations: [],
42
59
  };
43
60
 
44
61
  export const MAX_STORE_PEERS = 256;
62
+ export const MAX_STORE_MIGRATIONS = 64;
45
63
 
46
64
  function parsedArray<T>(value: unknown, schema: { safeParse: (input: unknown) => { success: boolean; data?: T } }): T[] {
47
65
  if (!Array.isArray(value)) return [];
@@ -98,6 +116,7 @@ export function readStoreState(path: string): StoreLoad {
98
116
  dependencies: parsedArray(value.dependencies, TaskDependency),
99
117
  events: parsedArray(value.events, MeshEvent),
100
118
  receipts: parsedArray(value.receipts, HandoffReceipt),
119
+ migrations: parsedArray(value.migrations, CapsuleMigration).slice(-MAX_STORE_MIGRATIONS),
101
120
  }),
102
121
  };
103
122
  } catch {
@@ -172,15 +191,39 @@ function collapseSplitChats(state: StoreState): StoreState {
172
191
  if (next.taskId === next.dependsOnTaskId) continue;
173
192
  dependencies.set(`${next.taskId}\0${next.dependsOnTaskId}`, next);
174
193
  }
194
+ // A capsule carries its Task id and is named by its hash; moving the id
195
+ // changes the hash, so every receipt that named the old hash names the new
196
+ // one, and the move itself is written down for a receipt still on its way.
197
+ const now = Date.now();
198
+ const migrations = new Map(state.migrations.map((item) => [item.capsuleHashFrom, item]));
199
+ const rehashed = new Map<string, string>();
200
+ const events = state.events.map((event) => {
201
+ const next = rescopedEvent(event, target);
202
+ const before = event.payload.capsule;
203
+ const after = next.payload.capsule;
204
+ if (before && after && event.taskId !== next.taskId) {
205
+ const from = capsuleHash(before);
206
+ const to = capsuleHash(after);
207
+ rehashed.set(from, to);
208
+ migrations.set(from, {
209
+ at: now, taskIdFrom: event.taskId, taskIdTo: next.taskId, capsuleHashFrom: from, capsuleHashTo: to,
210
+ });
211
+ }
212
+ return next;
213
+ });
214
+ const rehash = (hash: string): string => rehashed.get(hash) ?? hash;
175
215
  return {
176
216
  ...state,
177
217
  tasks: state.tasks.filter((task) => target(task.taskId) === task.taskId),
178
218
  executions: state.executions.map(scoped),
179
219
  claims: state.claims.map(scoped),
180
220
  dependencies: [...dependencies.values()],
181
- events: state.events.map((item) => rescopedEvent(item, target)),
182
- // A receipt decides who owns a chat, so it must name the surviving Task.
183
- receipts: state.receipts.map(scoped),
221
+ events: events.map((event) => event.payload.receipt
222
+ ? { ...event, payload: { ...event.payload, receipt: { ...event.payload.receipt, capsuleHash: rehash(event.payload.receipt.capsuleHash) } } }
223
+ : event),
224
+ // A receipt decides who owns a chat, so it must name the surviving Task and its capsule.
225
+ receipts: state.receipts.map((item) => ({ ...scoped(item), capsuleHash: rehash(item.capsuleHash) })),
226
+ migrations: [...migrations.values()].slice(-MAX_STORE_MIGRATIONS),
184
227
  };
185
228
  }
186
229
 
@@ -6,11 +6,14 @@
6
6
  * the last writer replaced whatever the other had written since: a Task
7
7
  * linked by one process vanished when the other saved a claim. A store now
8
8
  * reloads the file whenever it has changed under it, and saves by merging
9
- * what it changed since the last sync into what is on disk, under a short
10
- * lock beside the file.
9
+ * what it changed since the last sync into what is on disk, under a lock
10
+ * beside the file that is held by a named, living process.
11
11
  */
12
- import { chmodSync, existsSync, mkdirSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
13
- import { dirname } from "node:path";
12
+ import { randomBytes } from "node:crypto";
13
+ import {
14
+ chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync,
15
+ } from "node:fs";
16
+ import { dirname, join } from "node:path";
14
17
  import type {
15
18
  ExecutionSessionLink as ExecutionValue,
16
19
  IntegrationPeer as IntegrationPeerValue,
@@ -18,16 +21,18 @@ import type {
18
21
  } from "../../../../packages/protocol/schema";
19
22
  import { preferExecution, preferTask } from "./convergence";
20
23
  import { integrationPeerKey } from "./other-side";
21
- import { MAX_STORE_PEERS, type StoreState } from "./store-state";
24
+ import { MAX_STORE_MIGRATIONS, MAX_STORE_PEERS, type StoreState } from "./store-state";
22
25
 
23
26
  type Collection = Exclude<keyof StoreState, "version">;
24
27
 
25
28
  const COLLECTIONS: Collection[] = [
26
- "projects", "bindings", "peers", "tasks", "executions", "claims", "dependencies", "events", "receipts",
29
+ "projects", "bindings", "peers", "tasks", "executions", "claims", "dependencies", "events",
30
+ "receipts", "migrations",
27
31
  ];
28
- const BOUNDS: Partial<Record<Collection, number>> = { peers: MAX_STORE_PEERS, events: 512, receipts: 256 };
29
- const LOCK_STALE_MS = 5_000;
30
- const LOCK_WAIT_MS = 2_000;
32
+ const BOUNDS: Partial<Record<Collection, number>> = {
33
+ peers: MAX_STORE_PEERS, events: 512, receipts: 256, migrations: MAX_STORE_MIGRATIONS,
34
+ };
35
+ export const LOCK_WAIT_MS = 8_000;
31
36
  const LOCK_POLL_MS = 5;
32
37
 
33
38
  function rowKey(name: Collection, item: unknown): string {
@@ -42,27 +47,34 @@ function rowKey(name: Collection, item: unknown): string {
42
47
  case "dependencies": return [row.taskId, row.dependsOnTaskId].join("\0");
43
48
  case "events": return row.eventId ?? "";
44
49
  case "receipts": return row.capsuleHash ?? "";
50
+ case "migrations": return row.capsuleHashFrom ?? "";
45
51
  }
46
52
  }
47
53
 
48
54
  type Upsert = { item: unknown; before?: string };
49
- export type StoreDelta = Record<Collection, { upserts: Upsert[]; removed: string[] }>;
55
+ /** A removal names the row as it was read, so a row changed meanwhile is kept. */
56
+ type Removal = { key: string; before: string };
57
+ export type StoreDelta = Record<Collection, { upserts: Upsert[]; removed: Removal[] }>;
50
58
 
51
59
  /** What this process changed since it last agreed with the disk. */
52
60
  export function storeDelta(baseline: StoreState, current: StoreState): StoreDelta {
53
61
  const delta = {} as StoreDelta;
54
62
  for (const name of COLLECTIONS) {
55
63
  const before = new Map<string, string>();
56
- for (const item of baseline[name]) before.set(rowKey(name, item), JSON.stringify(item));
64
+ for (const item of baseline[name] ?? []) before.set(rowKey(name, item), JSON.stringify(item));
57
65
  const after = new Set<string>();
58
66
  const upserts: Upsert[] = [];
59
- for (const item of current[name]) {
67
+ for (const item of current[name] ?? []) {
60
68
  const key = rowKey(name, item);
61
69
  after.add(key);
62
70
  const previous = before.get(key);
63
71
  if (previous !== JSON.stringify(item)) upserts.push({ item, before: previous });
64
72
  }
65
- delta[name] = { upserts, removed: [...before.keys()].filter((key) => !after.has(key)) };
73
+ const removed: Removal[] = [];
74
+ for (const [key, previous] of before) {
75
+ if (!after.has(key)) removed.push({ key, before: previous });
76
+ }
77
+ delta[name] = { upserts, removed };
66
78
  }
67
79
  return delta;
68
80
  }
@@ -75,18 +87,22 @@ export function deltaIsEmpty(delta: StoreDelta): boolean {
75
87
  /**
76
88
  * This process's changes laid over what another process wrote meanwhile. A
77
89
  * row only we changed is ours; a row both changed is settled the way two
78
- * computers settle it, so every process converges on the same file.
90
+ * computers settle it, so every process converges on the same file. A row
91
+ * we removed is removed only as we read it: a claim renewed by another
92
+ * process since is not the claim we decided had expired.
79
93
  */
80
94
  export function applyStoreDelta(disk: StoreState, delta: StoreDelta): StoreState {
81
95
  const merged: Record<string, unknown> = { ...disk };
82
96
  for (const name of COLLECTIONS) {
83
97
  const { upserts, removed } = delta[name];
84
98
  if (upserts.length === 0 && removed.length === 0) continue;
85
- const gone = new Set(removed);
99
+ const gone = new Map(removed.map((removal) => [removal.key, removal.before]));
86
100
  const rows = new Map<string, unknown>();
87
- for (const item of disk[name]) {
101
+ for (const item of disk[name] ?? []) {
88
102
  const key = rowKey(name, item);
89
- if (!gone.has(key)) rows.set(key, item);
103
+ const asRead = gone.get(key);
104
+ if (asRead != null && asRead === JSON.stringify(item)) continue;
105
+ rows.set(key, item);
90
106
  }
91
107
  for (const { item, before } of upserts) {
92
108
  const key = rowKey(name, item);
@@ -131,7 +147,7 @@ export function writeStoreState(path: string, state: StoreState): void {
131
147
  export function setAsideStore(path: string, now = Date.now()): boolean {
132
148
  try {
133
149
  let target = `${path}.unreadable-${now}`;
134
- for (let attempt = 2; existsSync(target) && attempt < 100; attempt += 1) {
150
+ for (let attempt = 2; exists(target) && attempt < 100; attempt += 1) {
135
151
  target = `${path}.unreadable-${now}-${attempt}`;
136
152
  }
137
153
  renameSync(path, target);
@@ -141,49 +157,108 @@ export function setAsideStore(path: string, now = Date.now()): boolean {
141
157
  }
142
158
  }
143
159
 
160
+ function exists(path: string): boolean {
161
+ try {
162
+ statSync(path);
163
+ return true;
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
144
169
  function pause(ms: number): void {
145
170
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
146
171
  }
147
172
 
173
+ /** The lock could not be taken in time. The write is kept in memory and tried again later. */
174
+ export class StoreLockError extends Error {}
175
+
176
+ type LockOwner = { pid: number; token: string };
177
+
178
+ function lockOwner(lock: string): LockOwner | undefined {
179
+ try {
180
+ const [pid, token] = readFileSync(join(lock, "owner"), "utf8").trim().split(":");
181
+ const parsed = Number(pid);
182
+ return Number.isInteger(parsed) && parsed > 0 && token ? { pid: parsed, token } : undefined;
183
+ } catch {
184
+ return undefined;
185
+ }
186
+ }
187
+
188
+ function processAlive(pid: number): boolean {
189
+ try {
190
+ process.kill(pid, 0);
191
+ return true;
192
+ } catch (error) {
193
+ return (error as NodeJS.ErrnoException).code === "EPERM";
194
+ }
195
+ }
196
+
197
+ /** Take a lock whose owner is gone: moved aside first, so two waiters cannot both take it. */
198
+ function takeOverStale(lock: string): void {
199
+ const aside = `${lock}.stale-${process.pid}-${Date.now()}`;
200
+ try {
201
+ renameSync(lock, aside);
202
+ } catch {
203
+ return; // Someone else took it over first.
204
+ }
205
+ rmSync(aside, { recursive: true, force: true });
206
+ }
207
+
148
208
  /**
149
209
  * Run under the store's lock: a directory beside the file, the one thing a
150
- * file system creates atomically. A lock left by a process that died is
151
- * taken over after a few seconds; a lock that is merely slow is waited for,
152
- * briefly, and then the write goes ahead merged, which is still better than
153
- * a write that is lost.
210
+ * file system creates atomically, naming the process that holds it.
211
+ *
212
+ * A lock whose owner is no longer running is taken over at once; a lock whose
213
+ * owner is alive is waited for, and when the wait runs out the caller gets a
214
+ * StoreLockError instead of the critical section. A lock is released only by
215
+ * the process and token that took it, so an owner that finishes late never
216
+ * removes a lock taken over meanwhile.
154
217
  */
155
- export function withStoreLock<T>(path: string, run: () => T): T {
218
+ export function withStoreLock<T>(path: string, run: () => T, options: { waitMs?: number } = {}): T {
156
219
  mkdirSync(dirname(path), { recursive: true });
157
220
  const lock = `${path}.lock`;
221
+ const token = randomBytes(8).toString("hex");
222
+ const waitMs = options.waitMs ?? LOCK_WAIT_MS;
158
223
  const started = Date.now();
159
- let held = false;
160
- while (!held) {
224
+ for (;;) {
161
225
  try {
162
226
  mkdirSync(lock);
163
- held = true;
227
+ writeFileSync(join(lock, "owner"), `${process.pid}:${token}`, { mode: 0o600 });
228
+ break;
164
229
  } catch (error) {
165
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") break;
166
- if (Date.now() - started > LOCK_WAIT_MS) break;
167
- try {
168
- if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) {
169
- rmdirSync(lock);
170
- continue;
171
- }
172
- } catch {
230
+ const code = (error as NodeJS.ErrnoException).code;
231
+ if (code !== "EEXIST") throw new StoreLockError(`the store lock could not be taken (${code ?? String(error)})`);
232
+ const owner = lockOwner(lock);
233
+ if (owner && !processAlive(owner.pid)) {
234
+ takeOverStale(lock);
173
235
  continue;
174
236
  }
237
+ if (!owner && lockAge(lock) > waitMs) {
238
+ // A lock without an owner file for this long was never finished being taken.
239
+ takeOverStale(lock);
240
+ continue;
241
+ }
242
+ if (Date.now() - started > waitMs) {
243
+ throw new StoreLockError("the store lock is held by another running process");
244
+ }
175
245
  pause(LOCK_POLL_MS);
176
246
  }
177
247
  }
178
248
  try {
179
249
  return run();
180
250
  } finally {
181
- if (held) {
182
- try {
183
- rmdirSync(lock);
184
- } catch {
185
- // Taken over as stale by a process that waited longer than we ran.
186
- }
251
+ const owner = lockOwner(lock);
252
+ if (owner && owner.pid === process.pid && owner.token === token) {
253
+ rmSync(lock, { recursive: true, force: true });
187
254
  }
188
255
  }
189
256
  }
257
+
258
+ function lockAge(lock: string): number {
259
+ try {
260
+ return Date.now() - statSync(lock).mtimeMs;
261
+ } catch {
262
+ return 0;
263
+ }
264
+ }
@@ -25,6 +25,7 @@ import {
25
25
  setAsideStore,
26
26
  storeDelta,
27
27
  storeFingerprint,
28
+ StoreLockError,
28
29
  withStoreLock,
29
30
  writeStoreState,
30
31
  } from "./store-sync";
@@ -48,7 +49,15 @@ export class MeshStore {
48
49
  /** The file this process last read or wrote; another file under the path is news. */
49
50
  private synced: string | undefined;
50
51
 
51
- constructor(private readonly path: string, private readonly now = Date.now) {
52
+ /** How long a write waits for the store's lock before it is kept for later. */
53
+ private readonly lockWaitMs: number | undefined;
54
+
55
+ constructor(
56
+ private readonly path: string,
57
+ private readonly now = Date.now,
58
+ options: { lockWaitMs?: number } = {},
59
+ ) {
60
+ this.lockWaitMs = options.lockWaitMs;
52
61
  const loaded = readStoreState(path);
53
62
  this.state = loaded.state;
54
63
  this.baseline = structuredClone(loaded.state);
@@ -62,14 +71,16 @@ export class MeshStore {
62
71
  /**
63
72
  * Take in what another process wrote since this one last looked. Called on
64
73
  * entry to every public method, never in the middle of one, so a change
65
- * made and not yet saved is never thrown away.
74
+ * made and not yet saved is never thrown away: what this process changed
75
+ * and could not yet write is laid over what arrived.
66
76
  */
67
77
  private sync(): void {
68
78
  const current = storeFingerprint(this.path);
69
79
  if (current === this.synced) return;
70
80
  const loaded = readStoreState(this.path);
71
81
  if (loaded.status === "ok" || loaded.status === "missing") {
72
- this.state = loaded.state;
82
+ const pending = storeDelta(this.baseline, this.state);
83
+ this.state = deltaIsEmpty(pending) ? loaded.state : applyStoreDelta(loaded.state, pending);
73
84
  this.baseline = structuredClone(loaded.state);
74
85
  this.synced = current;
75
86
  return;
@@ -78,24 +89,55 @@ export class MeshStore {
78
89
  this.synced = storeFingerprint(this.path);
79
90
  }
80
91
 
81
- /** Write what changed here over what is on disk now, under the store's lock. */
82
- private save(): void {
83
- withStoreLock(this.path, () => {
84
- const delta = storeDelta(this.baseline, this.state);
85
- let merged = this.state;
86
- if (storeFingerprint(this.path) !== this.synced) {
87
- const disk = readStoreState(this.path);
88
- if (disk.status === "ok") {
89
- merged = deltaIsEmpty(delta) ? disk.state : applyStoreDelta(disk.state, delta);
90
- } else if (disk.status === "corrupt" || disk.status === "too_large") {
91
- setAsideStore(this.path, this.now());
92
+ /**
93
+ * Write what changed here over what is on disk now, under the store's lock.
94
+ * A lock that cannot be taken in time is not a reason to write anyway: the
95
+ * change stays in memory, ahead of the baseline, and the next save carries it.
96
+ */
97
+ private save(): boolean {
98
+ try {
99
+ withStoreLock(this.path, () => {
100
+ const delta = storeDelta(this.baseline, this.state);
101
+ let merged = this.state;
102
+ if (storeFingerprint(this.path) !== this.synced) {
103
+ const disk = readStoreState(this.path);
104
+ if (disk.status === "ok") {
105
+ merged = deltaIsEmpty(delta) ? disk.state : applyStoreDelta(disk.state, delta);
106
+ } else if (disk.status === "corrupt" || disk.status === "too_large") {
107
+ setAsideStore(this.path, this.now());
108
+ }
92
109
  }
93
- }
94
- writeStoreState(this.path, merged);
95
- this.state = merged;
96
- this.baseline = structuredClone(merged);
97
- this.synced = storeFingerprint(this.path);
98
- });
110
+ writeStoreState(this.path, merged);
111
+ this.state = merged;
112
+ this.baseline = structuredClone(merged);
113
+ this.synced = storeFingerprint(this.path);
114
+ }, { waitMs: this.lockWaitMs });
115
+ return true;
116
+ } catch (error) {
117
+ if (!(error instanceof StoreLockError)) throw error;
118
+ this.unsaved = true;
119
+ return false;
120
+ }
121
+ }
122
+
123
+ /** Whether the last write was held back by the lock; cleared by the next successful write. */
124
+ private unsaved = false;
125
+
126
+ /** Whether a change made here is still waiting for the lock to be written. */
127
+ get hasUnsavedChanges(): boolean {
128
+ return this.unsaved && !deltaIsEmpty(storeDelta(this.baseline, this.state));
129
+ }
130
+
131
+ /** Try again to write what the lock held back. */
132
+ flush(): boolean {
133
+ this.sync();
134
+ if (deltaIsEmpty(storeDelta(this.baseline, this.state))) {
135
+ this.unsaved = false;
136
+ return true;
137
+ }
138
+ const written = this.save();
139
+ if (written) this.unsaved = false;
140
+ return written;
99
141
  }
100
142
 
101
143
  upsertProject(input: ProjectValue): void {
@@ -396,8 +438,15 @@ export class MeshStore {
396
438
  && item.taskId === event.taskId
397
439
  && item.sourceSessionId === receipt.sourceSessionId
398
440
  && item.payload.capsule != null);
399
- return request?.payload.capsule != null
400
- && capsuleHash(request.payload.capsule) === receipt.capsuleHash;
441
+ if (request?.payload.capsule == null) return false;
442
+ const digest = capsuleHash(request.payload.capsule);
443
+ if (digest === receipt.capsuleHash) return true;
444
+ // A receipt written before this Task was rejoined names the capsule by
445
+ // the hash it had then; the move is on record, so the receipt still holds.
446
+ return this.state.migrations.some((item) =>
447
+ item.taskIdTo === event.taskId
448
+ && item.capsuleHashTo === digest
449
+ && item.capsuleHashFrom === receipt.capsuleHash);
401
450
  }
402
451
 
403
452
  private applyEvent(event: MeshEventValue): void {
@@ -61,6 +61,11 @@ export function rejectionReason(error: unknown): ProjectPolicyRejectionReason {
61
61
 
62
62
  export function createProjectPolicyRuntime(deps: ProjectPolicyRuntimeDependencies) {
63
63
  async function apply(relay: RelayClient, request: ProjectPolicySet): Promise<boolean> {
64
+ // The phone's edit is itself the enrollment: from this moment the Project
65
+ // is governed on this computer, whether or not the engine can take the
66
+ // policy right now, so a hook that finds the engine silent asks the
67
+ // person instead of falling open.
68
+ rememberGovernedProject(request.projectId, request.policy.revision, deps.now());
64
69
  try {
65
70
  const applied = await deps.client.request({
66
71
  operation: "policy.apply",
@@ -112,6 +112,29 @@ export function sensitivePath(path: unknown): boolean {
112
112
  return typeof path === "string" && SENSITIVE_PATH.test(path.trim());
113
113
  }
114
114
 
115
+ /**
116
+ * A file that is a secret by its name alone: an env file, a key, a
117
+ * credentials store. Narrower than `sensitivePath`, on purpose. Hiding a
118
+ * preview of `tokenizer.ts` costs a glance; leaving it out of a checkpoint
119
+ * loses the code, so a checkpoint excludes only what is a secret, not what
120
+ * merely sounds like one.
121
+ */
122
+ const SECRET_FILE = new RegExp(
123
+ "(^|[\\/])("
124
+ + "\\.env([.-][^\\/]*)?|[^\\/]+\\.env"
125
+ + "|[^\\/]*\\.(pem|key|p12|pfx|jks|keystore|der|gpg|asc|kdbx|ovpn|tfstate|tfstate\\.backup)"
126
+ + "|id_(rsa|ed25519|ecdsa|dsa)(\\.pub)?"
127
+ + "|credentials(\\.json|\\.ya?ml)?|secrets?(\\.json|\\.ya?ml|\\.toml)|[^\\/]*\\.secrets?"
128
+ + "|service[-_]account[^\\/]*\\.json"
129
+ + "|\\.npmrc|\\.netrc|\\.pypirc|\\.htpasswd|\\.git-credentials|\\.docker[\\/]config\\.json"
130
+ + ")$",
131
+ "i",
132
+ );
133
+
134
+ export function secretFilePath(path: unknown): boolean {
135
+ return typeof path === "string" && SECRET_FILE.test(path.trim());
136
+ }
137
+
115
138
  /** Bound the lines and their length, and say how many were left out. */
116
139
  function boundPreview(lines: string[], redact: (line: string) => string): string | undefined {
117
140
  const kept: string[] = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -131,6 +131,17 @@ export const TaskCapsule = z.object({
131
131
  resourceClaims: z.array(Path).max(64),
132
132
  remainingWork: z.array(Detail).max(32),
133
133
  importantDecisions: z.array(Detail).max(16),
134
+ /**
135
+ * What the checkpoint commit holds, when the capsule rides on one:
136
+ * complete, partial (secrets stayed on the source computer, named in
137
+ * `excluded`), or requires_review (the checkout was shared with other
138
+ * work, so the commit may carry changes that are not this Task's).
139
+ */
140
+ checkpoint: z.object({
141
+ status: z.enum(["complete", "partial", "requires_review"]),
142
+ files: z.number().int().nonnegative(),
143
+ excluded: z.array(Path).max(32),
144
+ }).strict().optional(),
134
145
  createdAt: z.number().nonnegative(),
135
146
  }).strict().superRefine((value, ctx) => {
136
147
  if ((value.sourceProvider === "grok_bot") !== (value.sourceActorId != null)) {