granttap-mcp 0.8.4 → 0.8.6
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.
- package/apps/bridge/src/attachment-store.ts +38 -1
- package/apps/bridge/src/mesh/admin.ts +37 -0
- package/apps/bridge/src/mesh/capsule.ts +28 -1
- package/apps/bridge/src/mesh/checkpoint.ts +20 -8
- package/apps/bridge/src/mesh/journal.ts +12 -5
- package/apps/bridge/src/mesh/prompt-context.ts +49 -24
- package/apps/bridge/src/mesh/runtime-handoff.ts +9 -0
- package/apps/bridge/src/mesh/runtime.ts +13 -2
- package/apps/bridge/src/mesh/store-state.ts +47 -4
- package/apps/bridge/src/mesh/store-sync.ts +115 -40
- package/apps/bridge/src/mesh/store.ts +71 -22
- package/apps/bridge/src/monitor.ts +6 -0
- package/apps/bridge/src/project-policy/runtime.ts +5 -0
- package/apps/bridge/src/sessions/edit-stats.ts +23 -0
- package/apps/mcp/src/mcp-tools/interaction.ts +113 -49
- package/apps/mcp/src/mcp-tools/operation-ledger.ts +53 -0
- package/package.json +1 -1
- package/packages/protocol/messages/mesh.ts +33 -0
- package/packages/protocol/schema.ts +2 -0
|
@@ -13,6 +13,9 @@ import type { UserAttachment, UserAttachmentUpload } from "../../../packages/pro
|
|
|
13
13
|
import { configDir } from "./config";
|
|
14
14
|
|
|
15
15
|
export const ATTACHMENT_TTL_MS = 2 * 60 * 60_000;
|
|
16
|
+
/** How many attachments may wait for their messages at once, and how much disk they may take. */
|
|
17
|
+
export const MAX_STAGED_ATTACHMENTS = 32;
|
|
18
|
+
export const MAX_STAGED_BYTES = 48 * 1_024 * 1_024;
|
|
16
19
|
|
|
17
20
|
function directory(): string {
|
|
18
21
|
const dir = join(configDir(), "attachments");
|
|
@@ -39,10 +42,44 @@ export function storeAttachment(upload: UserAttachmentUpload, room?: string, now
|
|
|
39
42
|
name: upload.name, mimeType: upload.mimeType, data: upload.data, receivedAt: now,
|
|
40
43
|
...(room ? { room } : {}),
|
|
41
44
|
};
|
|
42
|
-
|
|
45
|
+
const body = JSON.stringify(record);
|
|
46
|
+
// The staging area is bounded. One attachment too large for it is refused
|
|
47
|
+
// (the message that names it is rejected and the phone sends it inline);
|
|
48
|
+
// otherwise the oldest waiting ones make room, since a message that never
|
|
49
|
+
// came is the likeliest reason they are still here.
|
|
50
|
+
if (body.length > MAX_STAGED_BYTES) return false;
|
|
51
|
+
makeRoom(dir, body.length, id);
|
|
52
|
+
writeFileSync(join(dir, `${id}.json`), body, { mode: 0o600 });
|
|
43
53
|
return true;
|
|
44
54
|
}
|
|
45
55
|
|
|
56
|
+
function makeRoom(dir: string, incoming: number, incomingId: string): void {
|
|
57
|
+
let staged: Array<{ path: string; size: number; mtimeMs: number }> = [];
|
|
58
|
+
try {
|
|
59
|
+
staged = readdirSync(dir)
|
|
60
|
+
.filter((name) => name.endsWith(".json") && name !== `${incomingId}.json`)
|
|
61
|
+
.flatMap((name) => {
|
|
62
|
+
try {
|
|
63
|
+
const stat = statSync(join(dir, name));
|
|
64
|
+
return [{ path: join(dir, name), size: stat.size, mtimeMs: stat.mtimeMs }];
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
.sort((left, right) => left.mtimeMs - right.mtimeMs);
|
|
70
|
+
} catch {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
let count = staged.length;
|
|
74
|
+
let bytes = staged.reduce((total, item) => total + item.size, 0);
|
|
75
|
+
for (const item of staged) {
|
|
76
|
+
if (count < MAX_STAGED_ATTACHMENTS && bytes + incoming <= MAX_STAGED_BYTES) break;
|
|
77
|
+
rmSync(item.path, { force: true });
|
|
78
|
+
count -= 1;
|
|
79
|
+
bytes -= item.size;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
46
83
|
/** The attachment the message named, taken off disk; nothing when it never came. */
|
|
47
84
|
export function takeAttachment(attachmentId: string, room?: string, now = Date.now()): UserAttachment | undefined {
|
|
48
85
|
const id = safeId(attachmentId);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the person may do to the mesh that no agent may.
|
|
3
|
+
*
|
|
4
|
+
* A claim is released by its owner, and only its owner: that is what keeps
|
|
5
|
+
* one agent from clearing another's hold on a file. It also means a claim
|
|
6
|
+
* whose owner died, or will not let go, stays until it expires. The person
|
|
7
|
+
* is not an owner and is not bound by that rule; they are the authority the
|
|
8
|
+
* rule protects. A release from the phone is therefore a command of its
|
|
9
|
+
* own, checked against the Project it names, and written down.
|
|
10
|
+
*/
|
|
11
|
+
import type { MeshClaimRelease, ResourceClaim } from "../../../../packages/protocol/schema";
|
|
12
|
+
import type { MeshStore } from "./store";
|
|
13
|
+
|
|
14
|
+
export type PersonRelease =
|
|
15
|
+
| { released: true; claim: ResourceClaim }
|
|
16
|
+
| { released: false; reason: "unknown_claim" | "other_project" };
|
|
17
|
+
|
|
18
|
+
export function releaseClaimByPerson(
|
|
19
|
+
store: MeshStore,
|
|
20
|
+
request: MeshClaimRelease,
|
|
21
|
+
log: (line: string) => void = (line) => process.stderr.write(`[monitor] mesh: ${line}\n`),
|
|
22
|
+
): PersonRelease {
|
|
23
|
+
// The claim must be one of this Project's: a claim id is not a secret, and
|
|
24
|
+
// a Project's person does not reach into another Project with it.
|
|
25
|
+
const inProject = store.snapshot(request.projectId)?.claims.find((item) => item.claimId === request.claimId);
|
|
26
|
+
if (!inProject) {
|
|
27
|
+
const elsewhere = store.activeClaims().some((item) => item.claimId === request.claimId);
|
|
28
|
+
log(`release of ${request.claimId} refused: ${elsewhere ? "not in this Project" : "no such claim"}`);
|
|
29
|
+
return { released: false, reason: elsewhere ? "other_project" : "unknown_claim" };
|
|
30
|
+
}
|
|
31
|
+
store.releaseClaim(request.claimId);
|
|
32
|
+
log(
|
|
33
|
+
`claim ${request.claimId} on ${inProject.resource} held by ${inProject.ownerSessionId} `
|
|
34
|
+
+ `released by the person${request.reason ? `: ${request.reason}` : ""}`,
|
|
35
|
+
);
|
|
36
|
+
return { released: true, claim: inProject };
|
|
37
|
+
}
|
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
43
|
-
|
|
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(
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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
|
-
|
|
57
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
115
|
-
? createCheckpoint(cwd, request.taskId, session.title ?? request.taskId,
|
|
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:
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|