@bridge4dev/runner 0.26.0 → 0.29.0

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/dist/config.d.ts CHANGED
@@ -64,6 +64,25 @@ declare const ConfigSchema: z.ZodObject<{
64
64
  }, {
65
65
  enabled?: boolean | undefined;
66
66
  }>>;
67
+ /**
68
+ * Ticket #126: the machine owner's veto over restore points.
69
+ *
70
+ * A restore point is a copy of the working tree, kept on this machine, in an
71
+ * object store of the runner's own. That is disk this server's owner pays
72
+ * for and content they may not want duplicated at all — so the same rule as
73
+ * `[verify]` applies: `enabled = false` means the capability is not
74
+ * announced, the dashboard draws no rewind action, and no snapshot is ever
75
+ * taken. Secrets are excluded from a checkpoint in any case (`policy.ts`
76
+ * decides what counts), but "excluded" is a promise, and switching the whole
77
+ * thing off is a fact.
78
+ */
79
+ checkpoints: z.ZodOptional<z.ZodObject<{
80
+ enabled: z.ZodDefault<z.ZodBoolean>;
81
+ }, "strip", z.ZodTypeAny, {
82
+ enabled: boolean;
83
+ }, {
84
+ enabled?: boolean | undefined;
85
+ }>>;
67
86
  }, "strip", z.ZodTypeAny, {
68
87
  api: {
69
88
  url: string;
@@ -74,6 +93,9 @@ declare const ConfigSchema: z.ZodObject<{
74
93
  name: string;
75
94
  token: string;
76
95
  };
96
+ checkpoints?: {
97
+ enabled: boolean;
98
+ } | undefined;
77
99
  mcp?: {
78
100
  url: string;
79
101
  token: string;
@@ -97,6 +119,9 @@ declare const ConfigSchema: z.ZodObject<{
97
119
  name: string;
98
120
  token: string;
99
121
  };
122
+ checkpoints?: {
123
+ enabled?: boolean | undefined;
124
+ } | undefined;
100
125
  mcp?: {
101
126
  url: string;
102
127
  token: string;
package/dist/config.js CHANGED
@@ -59,6 +59,23 @@ const ConfigSchema = z.object({
59
59
  enabled: z.boolean().default(true),
60
60
  })
61
61
  .optional(),
62
+ /**
63
+ * Ticket #126: the machine owner's veto over restore points.
64
+ *
65
+ * A restore point is a copy of the working tree, kept on this machine, in an
66
+ * object store of the runner's own. That is disk this server's owner pays
67
+ * for and content they may not want duplicated at all — so the same rule as
68
+ * `[verify]` applies: `enabled = false` means the capability is not
69
+ * announced, the dashboard draws no rewind action, and no snapshot is ever
70
+ * taken. Secrets are excluded from a checkpoint in any case (`policy.ts`
71
+ * decides what counts), but "excluded" is a promise, and switching the whole
72
+ * thing off is a fact.
73
+ */
74
+ checkpoints: z
75
+ .object({
76
+ enabled: z.boolean().default(true),
77
+ })
78
+ .optional(),
62
79
  });
63
80
  export function loadConfig() {
64
81
  const file = configFilePath();
@@ -77,6 +77,20 @@ export interface AgentConfigContour {
77
77
  plugins: boolean;
78
78
  codexDir: boolean;
79
79
  codexConfig: boolean;
80
+ /**
81
+ * MCP servers an agent session in this home will actually see, and the ones
82
+ * that are configured but invisible to it.
83
+ *
84
+ * Both numbers, because the difference IS the bug. `claude mcp add` defaults
85
+ * to LOCAL scope, which stores the server under
86
+ * `~/.claude.json → projects["<cwd>"].mcpServers` — keyed by the directory it
87
+ * was added from. Runner sessions work in a per-session git worktree, so that
88
+ * key never matches and the servers are simply absent. On a live machine this
89
+ * read as «the agent lost Playwright and Context7», and the only cure was
90
+ * moving them to user scope (the top-level `mcpServers`).
91
+ */
92
+ mcpUserScope: number;
93
+ mcpProjectScope: number;
80
94
  }
81
95
  export declare function agentConfigContour(home?: string): AgentConfigContour;
82
96
  /**
@@ -116,6 +130,7 @@ export interface ToolCheck {
116
130
  /** Set when the tool is there but this user cannot use it. */
117
131
  problem?: string;
118
132
  }
133
+ export declare function whichExecutable(name: string): string | null;
119
134
  /**
120
135
  * Node, as THIS user sees it.
121
136
  *
@@ -114,6 +114,27 @@ export async function addSafeDirectory(repoPath) {
114
114
  timeout: 10_000,
115
115
  });
116
116
  }
117
+ /**
118
+ * Count MCP servers in the CLI's own config file, by scope.
119
+ *
120
+ * Reads `~/.claude.json` directly rather than shelling out to `claude mcp
121
+ * list`: this is a diagnostic that must work when the CLI is missing, and it
122
+ * must not spend ~300 MB and a second of a doctor run to answer.
123
+ */
124
+ function countMcpServers(home) {
125
+ try {
126
+ const parsed = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf8'));
127
+ const user = Object.keys(parsed.mcpServers ?? {}).length;
128
+ let project = 0;
129
+ for (const entry of Object.values(parsed.projects ?? {})) {
130
+ project += Object.keys(entry?.mcpServers ?? {}).length;
131
+ }
132
+ return { user, project };
133
+ }
134
+ catch {
135
+ return { user: 0, project: 0 };
136
+ }
137
+ }
117
138
  function countAllowRules(file) {
118
139
  try {
119
140
  const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -152,6 +173,7 @@ export function agentConfigContour(home = os.homedir()) {
152
173
  const localSettings = path.join(claudeDir, 'settings.local.json');
153
174
  const commandsDir = path.join(claudeDir, 'commands');
154
175
  const codexDir = path.join(home, '.codex');
176
+ const mcp = countMcpServers(home);
155
177
  return {
156
178
  home,
157
179
  claudeDir: fs.existsSync(claudeDir),
@@ -166,6 +188,8 @@ export function agentConfigContour(home = os.homedir()) {
166
188
  plugins: fs.existsSync(path.join(claudeDir, 'plugins')),
167
189
  codexDir: fs.existsSync(codexDir),
168
190
  codexConfig: fs.existsSync(path.join(codexDir, 'config.toml')),
191
+ mcpUserScope: mcp.user,
192
+ mcpProjectScope: mcp.project,
169
193
  };
170
194
  }
171
195
  /**
@@ -266,7 +290,7 @@ export function knownWorkspacePaths() {
266
290
  return [];
267
291
  }
268
292
  }
269
- function whichExecutable(name) {
293
+ export function whichExecutable(name) {
270
294
  for (const dir of (process.env['PATH'] ?? '').split(path.delimiter).filter(Boolean)) {
271
295
  const candidate = path.join(dir, name);
272
296
  try {
package/dist/index.js CHANGED
@@ -10,7 +10,8 @@ import { CodexAdapter } from './adapters/codex.js';
10
10
  import { ensureCodexHome } from './adapters/codex-home.js';
11
11
  import { loadConfig, requireConfig, saveConfig } from './config.js';
12
12
  import { log } from './log.js';
13
- import { installIsWritable, isSupervisedProcess, resolveInstalledPackageDir, } from './self-update.js';
13
+ import { installIsWritable, installPrefixFor, isSupervisedProcess, manualUpdateCommand, resolveInstalledPackageDir, } from './self-update.js';
14
+ import { applyStoredClaudeToken } from './agent-auth.js';
14
15
  import { Supervisor } from './supervisor.js';
15
16
  import { readStatusFile, isPidAlive, writeStatusFile, STATUS_FRESH_MS } from './status-file.js';
16
17
  import { RunnerWsClient } from './ws-client.js';
@@ -103,13 +104,20 @@ function hasExecutable(name) {
103
104
  * older runner drops a frame it cannot parse without ever replying, so a
104
105
  * command it does not know would just hang until the gateway timeout.
105
106
  */
106
- function runnerCapabilities() {
107
+ function runnerCapabilities(apiUrlOverride) {
107
108
  const config = loadConfig();
109
+ // At PAIR time there is no config yet, so the API this runner is about to
110
+ // belong to has to be passed in — otherwise the very first `hello` (the one
111
+ // that creates the server record) would omit the update command, and the
112
+ // dashboard would show a stale one until the daemon reconnected.
113
+ const apiUrl = apiUrlOverride ?? config?.api.url;
108
114
  const localLimit = config?.limits?.max_sessions;
109
115
  // Session 14: the machine owner's veto. Default on — the safety of the
110
116
  // feature is that a DevBridge manager approves every command first — but the
111
117
  // person who owns the server gets the last word on whether it exists here.
112
118
  const verifyEnabled = config?.verify?.enabled !== false;
119
+ // Ticket #126: the same veto, for restore points. Default on.
120
+ const checkpointsEnabled = config?.checkpoints?.enabled !== false;
113
121
  return {
114
122
  agents: installedAgents(),
115
123
  git: true,
@@ -135,6 +143,37 @@ function runnerCapabilities() {
135
143
  * is only half the instruction without a name to restart under.
136
144
  */
137
145
  runnerUser: runnerIdentity().user,
146
+ /**
147
+ * Which agent CLIs are actually on this user's PATH (0.27.0).
148
+ *
149
+ * Different question from `agents` above, and the difference cost a
150
+ * support round: `agents` says which agents can RUN sessions (Claude
151
+ * always can — the SDK bundles its own binary), while signing in needs the
152
+ * standalone CLI to exist for this user. On a dedicated-user install it
153
+ * routinely does not, and the sign-in button then failed with an error
154
+ * about `script`, about a machine whose real problem was that nobody had
155
+ * installed `claude` for that user at all.
156
+ */
157
+ agentClis: {
158
+ claude: hasExecutable('claude'),
159
+ codex: hasExecutable('codex'),
160
+ },
161
+ /**
162
+ * Where npm put this package, and the command that updates it here
163
+ * (0.27.0).
164
+ *
165
+ * The dashboard used to assemble the update command itself and then patch
166
+ * it with string surgery for the dedicated-user case. It cannot: only this
167
+ * process knows the prefix it was installed into, and `npm install -g`
168
+ * without that prefix is precisely the EACCES the owner pasted. So the
169
+ * machine states its own command and the panel just shows it.
170
+ */
171
+ ...(installPrefixFor() ? { npmPrefix: installPrefixFor() } : {}),
172
+ ...(apiUrl
173
+ ? {
174
+ updateCommand: manualUpdateCommand(`${apiUrl.replace(/\/$/, '')}/api/v1/dev-setup/runner.tgz`),
175
+ }
176
+ : {}),
138
177
  /**
139
178
  * A stricter ceiling set on the machine itself (layer 1). Reported so the
140
179
  * dashboard can explain why raising the number there changed nothing.
@@ -194,6 +233,22 @@ function runnerCapabilities() {
194
233
  ...(verifyEnabled ? { previewWorktrees: true } : {}),
195
234
  /** Session 14: one-shot agent-written commit messages. */
196
235
  commitMessages: true,
236
+ /**
237
+ * Ticket #126: restore points for the working tree, and the rewind that
238
+ * uses them. Withheld — not reported as false — when the machine's owner
239
+ * switched them off, by the same rule as `verifyRuns`.
240
+ */
241
+ ...(checkpointsEnabled
242
+ ? { sessionCheckpoints: true }
243
+ : { checkpointsBlocked: 'disabled-by-config' }),
244
+ /**
245
+ * Ticket #126: rewinding what the AGENT remembers, and `/compact` on
246
+ * demand. Both agents can do both — Claude through `resumeSessionAt` +
247
+ * `forkSession`, Codex through the stable `thread/fork { lastTurnId }` and
248
+ * `thread/compact/start` of app-server 0.145.0.
249
+ */
250
+ contextRewind: true,
251
+ contextCompaction: true,
197
252
  /**
198
253
  * Session 15: `git_status` reports whether the PROJECT FOLDER is clean, so
199
254
  * the panel can say what is blocking an Apply instead of offering a button
@@ -284,6 +339,17 @@ function runnerCapabilities() {
284
339
  'git_merge_abort',
285
340
  'recipe_state',
286
341
  'propose_commit_message',
342
+ 'recall_message',
343
+ 'compact_context',
344
+ ...(checkpointsEnabled
345
+ ? [
346
+ 'session_checkpoint',
347
+ 'session_checkpoints',
348
+ 'session_rewind_preview',
349
+ 'session_rewind_files',
350
+ 'context_rewind',
351
+ ]
352
+ : []),
287
353
  ...(verifyEnabled
288
354
  ? ['verify_start', 'verify_status', 'verify_cancel', 'preview_checkout', 'preview_stop']
289
355
  : []),
@@ -306,7 +372,7 @@ async function cmdPair(args) {
306
372
  name,
307
373
  runnerVersion: RUNNER_VERSION,
308
374
  osInfo: `${os.type()} ${os.release()} ${os.arch()}`.slice(0, 200),
309
- capabilities: runnerCapabilities(),
375
+ capabilities: runnerCapabilities(apiUrl),
310
376
  }),
311
377
  });
312
378
  const body = (await response.json().catch(() => null));
@@ -429,6 +495,12 @@ async function cmdDaemon() {
429
495
  log.info('daemon starting', { version: RUNNER_VERSION, server: config.server.name });
430
496
  sweepOrphanedMcpConfigs();
431
497
  await repairResourceLimits();
498
+ // A Claude token this runner captured through the sign-in relay. Applied
499
+ // BEFORE any adapter exists, because `scrubbedEnv()` copies it out of this
500
+ // process's environment for every session it starts.
501
+ if (applyStoredClaudeToken()) {
502
+ log.info('daemon: using the Claude token stored on this server');
503
+ }
432
504
  const agents = installedAgents();
433
505
  const ws = new RunnerWsClient(config.api.ws_url, config.server.token, runnerCapabilities());
434
506
  const codex = agents.includes('codex') ? bootstrapCodex(config) : null;
@@ -442,6 +514,7 @@ async function cmdDaemon() {
442
514
  // The same veto the capability list honours — announced AND enforced, so a
443
515
  // frame from an API that has not noticed still cannot start a run.
444
516
  verifyEnabled: config.verify?.enabled !== false,
517
+ checkpointsEnabled: config.checkpoints?.enabled !== false,
445
518
  apiUrl: config.api.url,
446
519
  // Used to fetch the files a user attaches to a message (session 10) — the
447
520
  // same token the WS connection authenticates with, never passed onwards.
@@ -654,8 +727,16 @@ async function runnerChecks() {
654
727
  const state = await systemctlProperty('ActiveState');
655
728
  const enabled = await systemctlProperty('UnitFileState');
656
729
  const running = state === 'active';
730
+ // Three separate promises, and the verdict has to fail on any of them.
731
+ // Until 0.27.0 only the first counted: a service that was running but
732
+ // would die at the next logout (linger off) or never come back after a
733
+ // reboot (not enabled) still printed ✔ and still said READY, with the
734
+ // `loginctl enable-linger` line sitting UNDER the tick as if it were
735
+ // advice. The whole point of an acceptance sheet is that a green one means
736
+ // walk away — so a runner that stops when its user logs out is a red line.
737
+ const durable = running && enabled === 'enabled' && linger !== false;
657
738
  checks.push({
658
- ok: running,
739
+ ok: durable,
659
740
  name: 'service',
660
741
  detail: running
661
742
  ? `running${enabled === 'enabled' ? ', starts on boot' : ' — but NOT enabled: it will not come back after a reboot'}` +
@@ -663,7 +744,7 @@ async function runnerChecks() {
663
744
  ? ', survives logout'
664
745
  : linger === false
665
746
  ? ', but linger is OFF: it stops when this user logs out'
666
- : '')
747
+ : ', linger state unknown')
667
748
  : `NOT running (${state ?? 'unknown'})`,
668
749
  ...(running
669
750
  ? enabled !== 'enabled'
@@ -725,10 +806,27 @@ async function runnerChecks() {
725
806
  ...(canUpdate || packageDir === null
726
807
  ? {}
727
808
  : {
728
- fix: `sudo -iu ${me.user} npm install -g --ignore-scripts --prefix ~/.local ${'@bridge4dev/runner'}`,
729
- fixMore: `(reinstalling as ${me.user} is what makes the button work; until then every update is a root command)`,
809
+ // NOT `--prefix ~/.local`, which is what this line used to say. The
810
+ // tilde is expanded by the CALLING shell, so run as root it aimed at
811
+ // /root/.local — a directory the daemon's user cannot write — and
812
+ // the install died naming a home nobody had chosen. `sudo -iu` hands
813
+ // the string to the TARGET user's login shell, so `$HOME` is theirs.
814
+ fix: `sudo -iu ${me.user} sh -lc 'npm config set prefix "$HOME/.local" && npm install -g --ignore-scripts --loglevel=error @bridge4dev/runner'`,
815
+ fixMore: `(the \`npm config set prefix\` half is what keeps the button working: without it every LATER update aims at the system prefix again and fails with EACCES)`,
730
816
  }),
731
817
  });
818
+ // The relay that signs an agent in runs the CLI on a pty, and util-linux
819
+ // `script` is what allocates it. Missing on minimal images, and its absence
820
+ // used to surface as «claude login exited before printing a sign-in URL».
821
+ const hasScript = hasExecutable('script');
822
+ checks.push({
823
+ ok: hasScript,
824
+ name: 'sign-in relay',
825
+ detail: hasScript
826
+ ? 'ready (util-linux `script` present)'
827
+ : '`script` (util-linux) is missing — the dashboard sign-in button cannot run',
828
+ ...(hasScript ? {} : { fix: 'apt-get install -y bsdextrautils util-linux' }),
829
+ });
732
830
  return checks;
733
831
  }
734
832
  async function agentChecks() {
@@ -744,12 +842,45 @@ async function agentChecks() {
744
842
  process.env['DEVBRIDGE_RUNNER_LOG'] = previousLogLevel;
745
843
  });
746
844
  const checks = [];
845
+ // Does the CLI exist for THIS user, before asking whether it is signed in?
846
+ // «not signed in» over a machine that has no `claude` at all sends the
847
+ // reader to a login screen for a command that does not exist — and doctor's
848
+ // own remedy (`sudo -iu <user> claude`) was then `command not found`. That
849
+ // is the state a dedicated-user install leaves behind by default.
850
+ const cliPresent = {
851
+ claude: hasExecutable('claude'),
852
+ codex: hasExecutable('codex'),
853
+ };
854
+ checks.push({
855
+ ok: cliPresent.claude,
856
+ name: 'claude cli',
857
+ detail: cliPresent.claude
858
+ ? `on ${me.user}'s PATH`
859
+ : `not installed for ${me.user} — sessions still run (the SDK bundles its own), but signing in needs the CLI`,
860
+ ...(cliPresent.claude
861
+ ? {}
862
+ : {
863
+ fix: `sudo -iu ${me.user} sh -lc 'curl -fsSL https://claude.ai/install.sh | bash'`,
864
+ }),
865
+ });
866
+ // Codex is optional: plenty of machines only ever run Claude sessions, and a
867
+ // red line for an agent nobody uses is noise that trains people to ignore
868
+ // the sheet. Reported, not judged.
869
+ checks.push({
870
+ ok: true,
871
+ name: 'codex cli',
872
+ detail: cliPresent.codex ? `on ${me.user}'s PATH` : `not installed for ${me.user} (optional)`,
873
+ ...(cliPresent.codex
874
+ ? {}
875
+ : {
876
+ fix: `sudo -iu ${me.user} sh -lc 'npm install -g @openai/codex' # only if you use Codex`,
877
+ }),
878
+ });
747
879
  for (const [agent, info] of [
748
880
  ['claude', auth.claude],
749
881
  ['codex', auth.codex],
750
882
  ]) {
751
883
  const signedIn = info.status === 'ok';
752
- const command = agent === 'claude' ? 'claude' : 'codex login';
753
884
  checks.push({
754
885
  ok: signedIn,
755
886
  name: `${agent} login`,
@@ -758,12 +889,39 @@ async function agentChecks() {
758
889
  ...(signedIn
759
890
  ? {}
760
891
  : {
761
- fix: `${me.isRoot ? command : `sudo -iu ${me.user} ${command}`}${agent === 'claude' ? ' then /login' : ''}`,
892
+ // The dashboard button FIRST. It is the product's own path, it
893
+ // needs no shell on the server, and an installing agent cannot
894
+ // perform an interactive OAuth login anyway — so telling it to
895
+ // «run claude and do /login» is telling it to stop. That is
896
+ // exactly where the last three installs stopped.
897
+ fix: `Dashboard → Development → this server → AGENTS → «Sign in» next to ${agent === 'claude' ? 'Claude Code' : 'Codex'}`,
898
+ fixMore: cliPresent[agent]
899
+ ? `or on the server: ${me.isRoot ? '' : `sudo -iu ${me.user} `}${agent === 'claude' ? 'claude auth login' : 'codex login'}`
900
+ : `(install the CLI first — see the «${agent} cli» line above)`,
762
901
  }),
763
902
  });
764
903
  }
765
904
  const contour = agentConfigContour(me.home);
766
905
  const elsewhere = otherHomeWithAgents(me);
906
+ // MCP servers a session will really see. Only user scope survives a session
907
+ // worktree, and «configured but in the wrong scope» looks identical to
908
+ // «working» from anywhere except inside a session.
909
+ const mcpHidden = contour.mcpUserScope === 0 && contour.mcpProjectScope > 0;
910
+ checks.push({
911
+ ok: !mcpHidden,
912
+ name: 'mcp servers',
913
+ detail: mcpHidden
914
+ ? `${contour.mcpProjectScope} configured, but all per-directory — a session works in its own worktree and will see NONE`
915
+ : contour.mcpUserScope > 0
916
+ ? `${contour.mcpUserScope} available to every session`
917
+ : 'none configured (the DevBridge server is injected per session regardless)',
918
+ ...(mcpHidden
919
+ ? {
920
+ fix: `sudo -iu ${me.user} claude mcp add --scope user <name> … # re-add at user scope`,
921
+ fixMore: '(`claude mcp add` defaults to the current directory’s scope, which no session shares)',
922
+ }
923
+ : {}),
924
+ });
767
925
  checks.push({
768
926
  ok: contour.claudeDir,
769
927
  name: 'claude config',
@@ -851,7 +1009,15 @@ async function projectChecks(target, fix) {
851
1009
  name: 'docker',
852
1010
  detail: docker.problem ?? 'usable by this user',
853
1011
  ...(docker.problem
854
- ? { fix: `usermod -aG docker ${me.user} (then restart the runner)` }
1012
+ ? {
1013
+ fix: `usermod -aG docker ${me.user}`,
1014
+ // Restarting the unit is NOT enough and this cost a diagnosis:
1015
+ // supplementary groups are fixed when logind creates
1016
+ // user@<uid>.service, so the daemon keeps the old set until the
1017
+ // whole user manager restarts. `id ${me.user}` then shows the new
1018
+ // group while /proc/<pid>/status still shows the old one.
1019
+ fixMore: `systemctl restart user@${me.uid}.service # the unit alone keeps the old group set`,
1020
+ }
855
1021
  : {}),
856
1022
  });
857
1023
  }
package/dist/journal.d.ts CHANGED
@@ -20,6 +20,16 @@ export interface PendingMessage {
20
20
  * session worktree is guaranteed to exist.
21
21
  */
22
22
  attachments?: PendingAttachment[];
23
+ /**
24
+ * Feed seq of the `message` event this text was echoed under (ticket #125).
25
+ *
26
+ * The only identifier the queue and the browser share: the API never sees
27
+ * `id` (it is minted here), and the `session_message` frame carries no id of
28
+ * its own — so the seq of the bubble the user is looking at is what a recall
29
+ * has to name. Absent on records written by a runner older than 0.28.0:
30
+ * those cannot be recalled, which is correct — they predate the feature.
31
+ */
32
+ seq?: number;
23
33
  }
24
34
  export interface PendingAttachment {
25
35
  id: string;
@@ -43,6 +53,11 @@ export declare class SessionJournal {
43
53
  extra?: Record<string, unknown>;
44
54
  epoch?: number;
45
55
  } | null;
56
+ /** The agent's conversation tip, and the provider session it lives in. */
57
+ lastAnchor: {
58
+ anchor: string;
59
+ providerSessionId: string;
60
+ } | null;
46
61
  constructor(sessionId: string, dir?: string);
47
62
  private replay;
48
63
  private write;
@@ -60,6 +75,14 @@ export declare class SessionJournal {
60
75
  * Statuses are fire-and-forget on the wire; journaling the latest one lets
61
76
  * the supervisor re-report it after a reconnect (QA-96 F1).
62
77
  */
78
+ /**
79
+ * Remember the conversation tip across a process gap (ticket #126).
80
+ *
81
+ * Written only when it actually moves: an agent that says nothing writes
82
+ * nothing, and the same line repeated every turn would grow the file for no
83
+ * reason.
84
+ */
85
+ recordAnchor(anchor: string, providerSessionId: string): void;
63
86
  recordStatus(status: string, extra?: Record<string, unknown>, epoch?: number): void;
64
87
  /** Assign the next seq and persist the event before it is sent. */
65
88
  append(eventType: string, payload: Record<string, unknown>): JournalEvent;
@@ -77,9 +100,19 @@ export declare class SessionJournal {
77
100
  * it is queued in memory, so the ordering is "on disk, then held" — a crash
78
101
  * between the two costs a duplicate delivery at worst, never a lost message.
79
102
  */
80
- appendPending(text: string, attachments?: PendingAttachment[]): PendingMessage;
103
+ appendPending(text: string, attachments?: PendingAttachment[], seq?: number): PendingMessage;
81
104
  /** The message reached an agent — stop replaying it after a restart. */
82
105
  resolvePending(id: string): void;
106
+ /**
107
+ * The user recalled the message before any agent saw it (ticket #125).
108
+ *
109
+ * Returns false when the record is already gone — which is the answer to
110
+ * "did I win the race", and the only safe way to ask it: the caller must
111
+ * report `already_delivered` rather than a success it cannot back up.
112
+ */
113
+ cancelPending(id: string): boolean;
114
+ /** The queued record echoed under this feed seq, if it is still queued. */
115
+ findPendingBySeq(seq: number): PendingMessage | undefined;
83
116
  /** Messages still waiting, oldest first (ids are minted in order). */
84
117
  pending(): PendingMessage[];
85
118
  get lastAssignedSeq(): number;
package/dist/journal.js CHANGED
@@ -19,6 +19,8 @@ export class SessionJournal {
19
19
  bytesOnDisk = 0;
20
20
  /** Last status reported for this session — replayed after a reconnect. */
21
21
  lastStatus = null;
22
+ /** The agent's conversation tip, and the provider session it lives in. */
23
+ lastAnchor = null;
22
24
  constructor(sessionId, dir = journalDir()) {
23
25
  this.sessionId = sessionId;
24
26
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -52,6 +54,9 @@ export class SessionJournal {
52
54
  else if (parsed.kind === 'ack') {
53
55
  this.unackedBySeq.delete(parsed.seq);
54
56
  }
57
+ else if (parsed.kind === 'anchor') {
58
+ this.lastAnchor = { anchor: parsed.anchor, providerSessionId: parsed.providerSessionId };
59
+ }
55
60
  else if (parsed.kind === 'status') {
56
61
  this.lastStatus = {
57
62
  status: parsed.status,
@@ -64,6 +69,7 @@ export class SessionJournal {
64
69
  id: parsed.id,
65
70
  text: parsed.text,
66
71
  ...(parsed.attachments?.length ? { attachments: parsed.attachments } : {}),
72
+ ...(typeof parsed.seq === 'number' ? { seq: parsed.seq } : {}),
67
73
  });
68
74
  // Ids are `p<n>`; keep the counter above whatever the file holds so a
69
75
  // restarted runner cannot mint an id that is already in flight.
@@ -71,7 +77,7 @@ export class SessionJournal {
71
77
  if (Number.isFinite(n) && n >= this.pendingCounter)
72
78
  this.pendingCounter = n + 1;
73
79
  }
74
- else if (parsed.kind === 'pending_done') {
80
+ else if (parsed.kind === 'pending_done' || parsed.kind === 'pending_cancelled') {
75
81
  this.pendingById.delete(parsed.id);
76
82
  }
77
83
  else if (parsed.kind === 'seq') {
@@ -98,6 +104,9 @@ export class SessionJournal {
98
104
  for (const event of this.unacked()) {
99
105
  lines.push({ kind: 'event', ...event, ts: new Date().toISOString() });
100
106
  }
107
+ if (this.lastAnchor) {
108
+ lines.push({ kind: 'anchor', ...this.lastAnchor });
109
+ }
101
110
  if (this.lastStatus) {
102
111
  lines.push({
103
112
  kind: 'status',
@@ -115,6 +124,7 @@ export class SessionJournal {
115
124
  id: record.id,
116
125
  text: record.text,
117
126
  ...(record.attachments?.length ? { attachments: record.attachments } : {}),
127
+ ...(record.seq === undefined ? {} : { seq: record.seq }),
118
128
  ts: new Date().toISOString(),
119
129
  });
120
130
  }
@@ -138,6 +148,21 @@ export class SessionJournal {
138
148
  * Statuses are fire-and-forget on the wire; journaling the latest one lets
139
149
  * the supervisor re-report it after a reconnect (QA-96 F1).
140
150
  */
151
+ /**
152
+ * Remember the conversation tip across a process gap (ticket #126).
153
+ *
154
+ * Written only when it actually moves: an agent that says nothing writes
155
+ * nothing, and the same line repeated every turn would grow the file for no
156
+ * reason.
157
+ */
158
+ recordAnchor(anchor, providerSessionId) {
159
+ if (this.lastAnchor?.anchor === anchor &&
160
+ this.lastAnchor.providerSessionId === providerSessionId) {
161
+ return;
162
+ }
163
+ this.lastAnchor = { anchor, providerSessionId };
164
+ this.write({ kind: 'anchor', anchor, providerSessionId });
165
+ }
141
166
  recordStatus(status, extra, epoch) {
142
167
  this.lastStatus = {
143
168
  status,
@@ -187,18 +212,20 @@ export class SessionJournal {
187
212
  * it is queued in memory, so the ordering is "on disk, then held" — a crash
188
213
  * between the two costs a duplicate delivery at worst, never a lost message.
189
214
  */
190
- appendPending(text, attachments) {
215
+ appendPending(text, attachments, seq) {
191
216
  const id = `p${this.pendingCounter++}`;
192
217
  const record = {
193
218
  id,
194
219
  text,
195
220
  ...(attachments?.length ? { attachments } : {}),
221
+ ...(seq === undefined ? {} : { seq }),
196
222
  };
197
223
  this.write({
198
224
  kind: 'pending',
199
225
  id,
200
226
  text,
201
227
  ...(attachments?.length ? { attachments } : {}),
228
+ ...(seq === undefined ? {} : { seq }),
202
229
  ts: new Date().toISOString(),
203
230
  });
204
231
  this.pendingById.set(id, record);
@@ -211,6 +238,28 @@ export class SessionJournal {
211
238
  this.pendingById.delete(id);
212
239
  this.write({ kind: 'pending_done', id });
213
240
  }
241
+ /**
242
+ * The user recalled the message before any agent saw it (ticket #125).
243
+ *
244
+ * Returns false when the record is already gone — which is the answer to
245
+ * "did I win the race", and the only safe way to ask it: the caller must
246
+ * report `already_delivered` rather than a success it cannot back up.
247
+ */
248
+ cancelPending(id) {
249
+ if (!this.pendingById.has(id))
250
+ return false;
251
+ this.pendingById.delete(id);
252
+ this.write({ kind: 'pending_cancelled', id });
253
+ return true;
254
+ }
255
+ /** The queued record echoed under this feed seq, if it is still queued. */
256
+ findPendingBySeq(seq) {
257
+ for (const record of this.pendingById.values()) {
258
+ if (record.seq === seq)
259
+ return record;
260
+ }
261
+ return undefined;
262
+ }
214
263
  /** Messages still waiting, oldest first (ids are minted in order). */
215
264
  pending() {
216
265
  return [...this.pendingById.values()];
package/dist/paths.d.ts CHANGED
@@ -22,6 +22,16 @@ export declare function worktreesDir(): string;
22
22
  export declare function knownWorkspacesPath(): string;
23
23
  /** Session 14: the single preview checkout per repository. */
24
24
  export declare function previewsDir(): string;
25
+ /**
26
+ * Ticket #126: git object stores holding the sessions' restore points.
27
+ *
28
+ * Deliberately under `devbridge-runner/`, and deliberately NOT under
29
+ * `worktrees/` or `previews/` — those two are excluded from the runner-tree
30
+ * rule in `SECRET_PATH_PATTERNS` (the agent has to be able to read its own
31
+ * workspace), so anything that must stay unreadable to the agent belongs
32
+ * exactly here.
33
+ */
34
+ export declare function checkpointsDir(): string;
25
35
  /**
26
36
  * Per-session MCP config files (ticket #119).
27
37
  *