@gajae-code/coding-agent 0.15.6 → 0.16.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.
Files changed (167) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/types/cli/args.d.ts +2 -0
  3. package/dist/types/cli/root-flags.d.ts +14 -0
  4. package/dist/types/cli/update-cli.d.ts +13 -0
  5. package/dist/types/cli.d.ts +7 -0
  6. package/dist/types/commands/launch.d.ts +10 -0
  7. package/dist/types/config/autorouting-tier-map.d.ts +24 -0
  8. package/dist/types/config/file-lock.d.ts +5 -0
  9. package/dist/types/config/model-registry.d.ts +6 -0
  10. package/dist/types/config/settings-schema.d.ts +10 -1
  11. package/dist/types/config/settings.d.ts +2 -0
  12. package/dist/types/coordinator-mcp/projection-scan.d.ts +8 -0
  13. package/dist/types/crash/record-loader.d.ts +15 -0
  14. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  15. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  16. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +66 -1
  17. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  18. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  19. package/dist/types/master-mode/context.d.ts +21 -0
  20. package/dist/types/master-mode/first-request.d.ts +34 -0
  21. package/dist/types/master-mode/snapshot.d.ts +32 -0
  22. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  23. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  24. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  25. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  26. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  27. package/dist/types/modes/print-mode.d.ts +17 -0
  28. package/dist/types/modes/ui-language.d.ts +7 -1
  29. package/dist/types/sdk/broker/broker.d.ts +77 -1
  30. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  31. package/dist/types/sdk/broker/index.d.ts +3 -0
  32. package/dist/types/sdk/broker/lifecycle.d.ts +26 -0
  33. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  34. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  35. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  36. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  37. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  38. package/dist/types/sdk/broker/state-version.d.ts +4 -1
  39. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  40. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  41. package/dist/types/sdk/bus/index.d.ts +6 -0
  42. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  43. package/dist/types/sdk/cli/index.d.ts +1 -0
  44. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  45. package/dist/types/sdk/cli/rows.d.ts +4 -3
  46. package/dist/types/sdk/cli/session-cli.d.ts +18 -2
  47. package/dist/types/sdk/client/client.d.ts +3 -2
  48. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  49. package/dist/types/sdk/host/control/index.d.ts +1 -1
  50. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  51. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  52. package/dist/types/sdk/host/host.d.ts +10 -0
  53. package/dist/types/sdk/host/session-runtime.d.ts +52 -0
  54. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  55. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  56. package/dist/types/sdk/lifecycle-session.d.ts +2 -0
  57. package/dist/types/sdk/router/session-router.d.ts +2 -0
  58. package/dist/types/sdk/session.d.ts +16 -0
  59. package/dist/types/sdk/startup-capability.d.ts +2 -1
  60. package/dist/types/session/agent-session.d.ts +5 -0
  61. package/dist/types/session/credential-store-errors.d.ts +1 -0
  62. package/dist/types/session/session-manager.d.ts +65 -0
  63. package/dist/types/session/yield-queue.d.ts +5 -4
  64. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  65. package/dist/types/tools/bash.d.ts +4 -0
  66. package/dist/types/tools/gh.d.ts +8 -0
  67. package/dist/types/tools/index.d.ts +4 -0
  68. package/package.json +7 -7
  69. package/scripts/generate-sdk-operation-inventory.ts +6 -0
  70. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  71. package/src/cli/accounts-cli.ts +9 -5
  72. package/src/cli/args.ts +17 -0
  73. package/src/cli/fast-help.ts +1 -0
  74. package/src/cli/root-flags.ts +5 -0
  75. package/src/cli/update-cli.ts +77 -11
  76. package/src/commands/launch.ts +86 -18
  77. package/src/commands/sdk.ts +113 -21
  78. package/src/config/autorouting-tier-map.ts +12 -0
  79. package/src/config/file-lock.ts +158 -83
  80. package/src/config/model-preset-registry.ts +1 -5
  81. package/src/config/model-profiles.ts +5 -5
  82. package/src/config/model-registry.ts +23 -0
  83. package/src/config/model-resolver.ts +5 -0
  84. package/src/config/settings-schema.ts +8 -1
  85. package/src/config/settings.ts +5 -0
  86. package/src/coordinator-mcp/projection-scan.ts +21 -0
  87. package/src/coordinator-mcp/question-state.ts +9 -11
  88. package/src/coordinator-mcp/server.ts +94 -20
  89. package/src/crash/index-store.ts +2 -0
  90. package/src/crash/record-loader.ts +10 -0
  91. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  92. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  93. package/src/gjc-runtime/session-state-lock.ts +79 -6
  94. package/src/gjc-runtime/session-state-sidecar.ts +929 -23
  95. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  96. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -2
  97. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  98. package/src/hooks/skill-state.ts +6 -0
  99. package/src/internal-urls/docs-index.generated.ts +7 -7
  100. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  101. package/src/main.ts +57 -2
  102. package/src/master-mode/context.ts +50 -0
  103. package/src/master-mode/first-request.ts +65 -0
  104. package/src/master-mode/snapshot.ts +205 -0
  105. package/src/modes/acp/acp-agent.ts +605 -156
  106. package/src/modes/components/footer.ts +4 -2
  107. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  108. package/src/modes/components/oauth-selector.ts +6 -1
  109. package/src/modes/components/settings-selector.ts +1 -0
  110. package/src/modes/components/status-line/model-name.ts +53 -0
  111. package/src/modes/components/status-line/priority-row.ts +107 -0
  112. package/src/modes/components/status-line/segments.ts +23 -9
  113. package/src/modes/components/tool-status-header.ts +151 -15
  114. package/src/modes/controllers/input-controller.ts +28 -0
  115. package/src/modes/controllers/selector-controller.ts +191 -96
  116. package/src/modes/print-mode.ts +64 -0
  117. package/src/modes/ui-language.ts +49 -2
  118. package/src/prompts/system/master-mode.md +28 -0
  119. package/src/sdk/broker/broker.ts +1636 -26
  120. package/src/sdk/broker/endpoint-authority.ts +86 -0
  121. package/src/sdk/broker/ensure.ts +5 -0
  122. package/src/sdk/broker/index.ts +3 -0
  123. package/src/sdk/broker/lifecycle.ts +186 -41
  124. package/src/sdk/broker/master-capability.ts +232 -0
  125. package/src/sdk/broker/session-index.ts +121 -17
  126. package/src/sdk/broker/session-scope.ts +272 -0
  127. package/src/sdk/broker/spawn-authority.ts +1031 -0
  128. package/src/sdk/broker/spawn-substrate.ts +548 -0
  129. package/src/sdk/broker/state-version.ts +26 -14
  130. package/src/sdk/broker/transport.ts +1 -0
  131. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  132. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  133. package/src/sdk/bus/chat-daemon-runtime.ts +35 -4
  134. package/src/sdk/bus/index.ts +51 -4
  135. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  136. package/src/sdk/cli/index.ts +1 -0
  137. package/src/sdk/cli/master-cli.ts +259 -0
  138. package/src/sdk/cli/rows.ts +11 -8
  139. package/src/sdk/cli/session-cli.ts +264 -5
  140. package/src/sdk/client/client.ts +14 -8
  141. package/src/sdk/host/control/dispatch.ts +99 -38
  142. package/src/sdk/host/control/index.ts +2 -0
  143. package/src/sdk/host/control/operations.ts +2 -0
  144. package/src/sdk/host/control/runtime-gate.ts +203 -11
  145. package/src/sdk/host/host.ts +27 -2
  146. package/src/sdk/host/session-runtime.test.ts +210 -6
  147. package/src/sdk/host/session-runtime.ts +291 -49
  148. package/src/sdk/lifecycle/broker-client.ts +28 -0
  149. package/src/sdk/lifecycle/service.ts +279 -20
  150. package/src/sdk/lifecycle-session.ts +10 -2
  151. package/src/sdk/protocol/operation-inventory.generated.json +51 -0
  152. package/src/sdk/protocol/operation-registry.ts +13 -3
  153. package/src/sdk/router/session-router.ts +67 -59
  154. package/src/sdk/session.ts +357 -155
  155. package/src/sdk/startup-capability.ts +1 -0
  156. package/src/session/agent-session.ts +640 -103
  157. package/src/session/blob-store.ts +15 -5
  158. package/src/session/credential-store-errors.ts +2 -0
  159. package/src/session/internal/managed-session-storage.ts +153 -78
  160. package/src/session/session-manager.ts +337 -24
  161. package/src/session/yield-queue.ts +24 -10
  162. package/src/setup/frictionless-onboarding.ts +2 -0
  163. package/src/telemetry/events.ts +1138 -12
  164. package/src/tools/bash.ts +256 -42
  165. package/src/tools/gh.ts +104 -48
  166. package/src/tools/github-cache.ts +2 -2
  167. package/src/tools/index.ts +4 -0
@@ -375,8 +375,10 @@ function buildSingleResource({
375
375
 
376
376
  function formatFileLine(idx: number, file: PrDiffFile, repo: string, prNumber: number): string {
377
377
  const stats = file.changeType === "binary" ? "(binary)" : `+${file.additions} -${file.deletions}`;
378
- const rename = file.oldPath ? ` (renamed from ${file.oldPath})` : "";
379
- return `${idx}. ${file.path} ${stats} [${file.changeType}]${rename}\n pr://${repo}/${prNumber}/diff/${idx}`;
378
+ const escapedNote = file.pathEscaped ? " (escaped non-UTF-8 name)" : "";
379
+ const renameNote = file.oldPathEscaped ? " (escaped non-UTF-8 name)" : "";
380
+ const rename = file.oldPath ? ` (renamed from ${file.oldPath}${renameNote})` : "";
381
+ return `${idx}. ${file.path}${escapedNote} ${stats} [${file.changeType}]${rename}\n pr://${repo}/${prNumber}/diff/${idx}`;
380
382
  }
381
383
 
382
384
  async function fetchAndRenderPrDiff(
package/src/main.ts CHANGED
@@ -39,7 +39,9 @@ import { BUNDLED_GROK_BUILD_EXTENSION_ID, getBundledGrokBuildExtensionFactory }
39
39
  import { initializeWithSettings } from "./discovery";
40
40
  import { exportFromFile } from "./export/html";
41
41
  import type { ExtensionUIContext } from "./extensibility/extensions/types";
42
+ import { releaseLaunchWorktreeReservationAfterRegistration } from "./gjc-runtime/launch-worktree-reservation";
42
43
  import { persistCoordinatorRuntimeInputReady } from "./gjc-runtime/session-state-sidecar";
44
+ import { assertMasterLaunchArgs, assertMasterLaunchDisposition, createMasterModeContext } from "./master-mode/context";
43
45
  import type { AcpStartupOptions } from "./modes/acp/startup-options";
44
46
  import type { SessionSelectionResult } from "./modes/components/session-selector";
45
47
  import type { InteractiveMode } from "./modes/interactive-mode";
@@ -56,7 +58,7 @@ import {
56
58
  discoverAuthStorage,
57
59
  } from "./sdk";
58
60
  import { processIncarnation } from "./sdk/broker/process-incarnation";
59
- import { SessionIndex } from "./sdk/broker/session-index";
61
+ import { newMasterAttestationEpoch, resolveSessionLocator, SessionIndex } from "./sdk/broker/session-index";
60
62
  import type { AgentSession } from "./session/agent-session";
61
63
  import { SessionMigrationBusyError } from "./session/internal/session-open-errors";
62
64
  import {
@@ -752,6 +754,9 @@ export async function runInteractiveMode(
752
754
  if (
753
755
  shouldOfferAutomaticOnboarding({
754
756
  normalInteractive: true,
757
+ automation:
758
+ /^(?:1|true|yes|on)$/i.test(process.env.CI ?? "") ||
759
+ /^(?:1|true|yes|on)$/i.test(process.env.GJC_AUTOMATION ?? ""),
755
760
  initialMessage,
756
761
  initialMessages,
757
762
  initialImages,
@@ -1366,6 +1371,7 @@ export async function runRootCommand(
1366
1371
  deps: RunRootCommandDependencies = {},
1367
1372
  ): Promise<void> {
1368
1373
  const parsedArgs = parsed;
1374
+ assertMasterLaunchArgs(parsedArgs);
1369
1375
  let initialThemeInitialized = false;
1370
1376
  let autoChdirApplied = false;
1371
1377
  let bareResumeSessionManager: SessionManager | undefined;
@@ -1553,6 +1559,22 @@ export async function runRootCommand(
1553
1559
  print: Boolean(parsedArgs.print),
1554
1560
  mode: parsedArgs.mode,
1555
1561
  });
1562
+ // Master admission runs unconditionally and BEFORE any session state is
1563
+ // opened or created. Nesting it under nonInteractiveError skipped it for a
1564
+ // non-TTY launch carrying prepared input, which resolves to autoPrint with no
1565
+ // error, so `--master <prompt>` proceeded as an ordinary auto-print run.
1566
+ try {
1567
+ assertMasterLaunchDisposition({
1568
+ master: parsedArgs.master,
1569
+ isInteractive: disposition.isInteractive,
1570
+ autoPrint: disposition.autoPrint,
1571
+ nonInteractiveError: disposition.nonInteractiveError,
1572
+ });
1573
+ } catch (error) {
1574
+ process.stderr.write(`${chalk.red(error instanceof Error ? error.message : "Invalid master launch")}\n`);
1575
+ if (!deps.suppressProcessExit) process.exitCode = 1;
1576
+ return;
1577
+ }
1556
1578
  if (disposition.nonInteractiveError) {
1557
1579
  process.stderr.write(`${chalk.red(disposition.nonInteractiveError)}\n`);
1558
1580
  process.exit(1);
@@ -1676,6 +1698,14 @@ export async function runRootCommand(
1676
1698
  }
1677
1699
  }
1678
1700
  }
1701
+ const masterModeContext =
1702
+ parsedArgs.master === true && sessionManager
1703
+ ? createMasterModeContext(
1704
+ parsedArgs.masterScope ?? "repo",
1705
+ sessionManager.getSessionId(),
1706
+ newMasterAttestationEpoch(),
1707
+ )
1708
+ : undefined;
1679
1709
 
1680
1710
  const { options: sessionOptions } = await logger.time(
1681
1711
  "buildSessionOptions",
@@ -1742,6 +1772,7 @@ export async function runRootCommand(
1742
1772
  sessionOptions.notificationHostModeSupported = isInteractive;
1743
1773
  sessionOptions.sdkHostModeSupported = isInteractive;
1744
1774
  sessionOptions.settings = settingsInstance;
1775
+ sessionOptions.masterModeContext = masterModeContext;
1745
1776
  if (isInteractive && sessionOptions.mcpConfigPath) {
1746
1777
  sessionOptions.deferMcpConfigStartup = true;
1747
1778
  }
@@ -1826,7 +1857,7 @@ export async function runRootCommand(
1826
1857
  const directSessionId = process.env.GJC_LIFECYCLE_REQUEST_ID ? undefined : sessionManager?.getSessionId();
1827
1858
  if (directSessionId) {
1828
1859
  const sessionIndex = new SessionIndex(settingsInstance.getAgentDir());
1829
- const locator = { repo: sessionManager?.getCwd() ?? cwd, stateRoot: settingsInstance.getAgentDir() };
1860
+ const locator = await resolveSessionLocator(sessionManager?.getCwd() ?? cwd, settingsInstance.getAgentDir());
1830
1861
  // A pid is reusable, so the broker's teardown fence needs this session's OS
1831
1862
  // start incarnation recorded alongside the pid it publishes.
1832
1863
  const directSessionIncarnation = processIncarnation(process.pid);
@@ -1837,7 +1868,31 @@ export async function runRootCommand(
1837
1868
  endpointGeneration: 0,
1838
1869
  pid: process.pid,
1839
1870
  ...(directSessionIncarnation ? { processIncarnation: directSessionIncarnation } : {}),
1871
+ ...(masterModeContext && directSessionIncarnation
1872
+ ? {
1873
+ masterRole: {
1874
+ version: 2,
1875
+ ownerSessionId: masterModeContext.ownerSessionId,
1876
+ launchPid: process.pid,
1877
+ launchProcessIncarnation: directSessionIncarnation,
1878
+ role: "master" as const,
1879
+ attestationEpoch: masterModeContext.attestationEpoch,
1880
+ },
1881
+ }
1882
+ : {}),
1840
1883
  });
1884
+ if (locator.worktreeRoot !== null) {
1885
+ try {
1886
+ await releaseLaunchWorktreeReservationAfterRegistration(
1887
+ settingsInstance.getAgentDir(),
1888
+ locator.worktreeRoot,
1889
+ );
1890
+ } catch (error) {
1891
+ logger.warn("Failed to release launch worktree reservation after host registration", {
1892
+ error: error instanceof Error ? error.message : String(error),
1893
+ });
1894
+ }
1895
+ }
1841
1896
  postmortem.register("direct-session-index", async () => {
1842
1897
  await sessionIndex.append({
1843
1898
  type: "host_unregistered",
@@ -0,0 +1,50 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import type { Args } from "../cli/args";
3
+
4
+ export type MasterScope = "repo" | "pwd" | "global";
5
+
6
+ /**
7
+ * Process-local master authority. Its capability accessor is deliberately the
8
+ * only way to reach the raw capability; this context is never serializable.
9
+ */
10
+ export interface MasterModeContext {
11
+ readonly scope: MasterScope;
12
+ readonly ownerSessionId: string;
13
+ readonly attestationEpoch: string;
14
+ getCapability(): string;
15
+ }
16
+
17
+ export function createMasterModeContext(
18
+ scope: MasterScope,
19
+ ownerSessionId: string,
20
+ attestationEpoch: string,
21
+ ): MasterModeContext {
22
+ const capability = randomBytes(32).toString("base64url");
23
+ return { scope, ownerSessionId, attestationEpoch, getCapability: () => capability };
24
+ }
25
+
26
+ /** Reject routes that cannot establish an interactive, durable master session. */
27
+ export function assertMasterLaunchArgs(parsed: Args): void {
28
+ if (parsed.master !== true) return;
29
+ if (
30
+ parsed.print === true ||
31
+ parsed.mode !== undefined ||
32
+ parsed.export !== undefined ||
33
+ parsed.listModels !== undefined ||
34
+ parsed.noSession === true
35
+ ) {
36
+ throw new Error("--master requires an interactive durable TUI launch");
37
+ }
38
+ }
39
+
40
+ export function assertMasterLaunchDisposition(input: {
41
+ master: boolean | undefined;
42
+ isInteractive: boolean;
43
+ autoPrint: boolean;
44
+ nonInteractiveError?: string;
45
+ }): void {
46
+ if (input.master !== true) return;
47
+ if (!input.isInteractive || input.autoPrint || input.nonInteractiveError !== undefined) {
48
+ throw new Error("--master requires an interactive TTY launch");
49
+ }
50
+ }
@@ -0,0 +1,65 @@
1
+ import { canonicalSessionCwd, sessionWorktreeRoot } from "../sdk/broker/session-index";
2
+ import type { MasterScope } from "./context";
3
+ import { collectMasterPeerSnapshot, type MasterPeerSnapshotLifecycle, renderMasterPeerSnapshot } from "./snapshot";
4
+
5
+ export const MASTER_PEER_SNAPSHOT_CUSTOM_TYPE = "master-peer-snapshot";
6
+
7
+ /** The injected message shape consumed by the agent-session contributor seam. */
8
+ export interface MasterPeerSnapshotMessage {
9
+ customType: string;
10
+ content: string;
11
+ display: false;
12
+ attribution: "agent";
13
+ }
14
+
15
+ export interface MasterPeerSnapshotContributorInput {
16
+ readonly lifecycle: MasterPeerSnapshotLifecycle;
17
+ readonly ownerSessionId: string;
18
+ /** Current transcript/session identity; differs from ownerSessionId after a branch or switch. */
19
+ readonly getSessionId?: () => string;
20
+ readonly scope: MasterScope;
21
+ /** Live cwd accessor; resolved at injection time, not at registration. */
22
+ readonly getCwd: () => string;
23
+ /**
24
+ * Whether a prior injection was persisted to the session branch. A persisted
25
+ * block proves a first request was accepted, so later turns never duplicate
26
+ * it; a pre-accept cancellation persists nothing and the next attempt
27
+ * re-collects a fresh snapshot.
28
+ */
29
+ readonly hasPersistedInjection: () => boolean;
30
+ readonly onError?: (error: unknown) => void;
31
+ }
32
+
33
+ /**
34
+ * Builds the one-shot before-agent-start contributor that injects the scoped,
35
+ * no-probe master peer snapshot immediately before the first accepted provider
36
+ * request. Idle masters never collect: the snapshot is gathered only when a
37
+ * prompt actually starts.
38
+ */
39
+ export function createMasterPeerSnapshotContributor(
40
+ input: MasterPeerSnapshotContributorInput,
41
+ ): () => Promise<MasterPeerSnapshotMessage | undefined> {
42
+ return async () => {
43
+ try {
44
+ if (input.hasPersistedInjection()) return undefined;
45
+ const anchorCwd = await canonicalSessionCwd(input.getCwd());
46
+ const snapshot = await collectMasterPeerSnapshot({
47
+ lifecycle: input.lifecycle,
48
+ actor: { id: input.ownerSessionId, namespace: "sdk:master-snapshot" },
49
+ ownerSessionId: input.ownerSessionId,
50
+ ...(input.getSessionId === undefined ? {} : { currentSessionId: input.getSessionId() }),
51
+ scope: input.scope,
52
+ requestAnchor: { cwd: anchorCwd, worktreeRoot: await sessionWorktreeRoot(anchorCwd) },
53
+ });
54
+ return {
55
+ customType: MASTER_PEER_SNAPSHOT_CUSTOM_TYPE,
56
+ content: renderMasterPeerSnapshot(snapshot),
57
+ display: false,
58
+ attribution: "agent",
59
+ };
60
+ } catch (error) {
61
+ input.onError?.(error);
62
+ return undefined;
63
+ }
64
+ };
65
+ }
@@ -0,0 +1,205 @@
1
+ import {
2
+ type ResolvedScopeV1,
3
+ resolveScopeRequest,
4
+ type ScopeNameV1,
5
+ type ScopeRequestV1,
6
+ type SdkSearchResultV1,
7
+ type SdkSearchRowV1,
8
+ sdkSearchResultV1,
9
+ } from "../sdk/broker/session-scope";
10
+ import type { SessionLifecycleActor, SessionListOutcome, SessionListRequest } from "../sdk/lifecycle/service";
11
+
12
+ export type MasterPeerSnapshot = {
13
+ readonly status: SdkSearchResultV1["status"];
14
+ readonly scope: ResolvedScopeV1;
15
+ readonly observedAt: string;
16
+ readonly indexSeq?: number;
17
+ readonly rows: readonly SdkSearchRowV1[];
18
+ readonly truncated?: boolean;
19
+ };
20
+
21
+ const MASTER_PEER_SNAPSHOT_MAX_ROWS = 256;
22
+ const MASTER_PEER_SNAPSHOT_MAX_BYTES = 64 * 1024;
23
+
24
+ /** The scoped Broker surface needed for the one-time master peer snapshot. */
25
+ export interface MasterPeerSnapshotLifecycle {
26
+ list(request: Omit<SessionListRequest, "operation">): Promise<SessionListOutcome>;
27
+ }
28
+
29
+ export interface CollectMasterPeerSnapshotInput {
30
+ readonly lifecycle: MasterPeerSnapshotLifecycle;
31
+ readonly actor: SessionLifecycleActor;
32
+ readonly ownerSessionId: string;
33
+ readonly currentSessionId?: string;
34
+ readonly scope: ScopeNameV1;
35
+ readonly requestAnchor: ScopeRequestV1["requestAnchor"];
36
+ readonly timeoutMs?: number;
37
+ }
38
+
39
+ function compareText(left: string, right: string): number {
40
+ return left < right ? -1 : left > right ? 1 : 0;
41
+ }
42
+
43
+ /**
44
+ * Orders by session ID, then the complete canonical locator as a deterministic
45
+ * tie-breaker. The Broker's `live` value is index evidence, not a fresh probe.
46
+ */
47
+ function compareRows(left: SdkSearchRowV1, right: SdkSearchRowV1): number {
48
+ const id = compareText(left.id, right.id);
49
+ if (id !== 0) return id;
50
+ const cwd = compareText(left.locator.cwd, right.locator.cwd);
51
+ if (cwd !== 0) return cwd;
52
+ const worktreeRoot = compareText(left.locator.worktreeRoot ?? "", right.locator.worktreeRoot ?? "");
53
+ if (worktreeRoot !== 0) return worktreeRoot;
54
+ return compareText(left.locator.stateRoot, right.locator.stateRoot);
55
+ }
56
+
57
+ function unavailableSnapshot(scope: ResolvedScopeV1): MasterPeerSnapshot {
58
+ return {
59
+ status: "unavailable",
60
+ scope,
61
+ observedAt: new Date().toISOString(),
62
+ rows: [],
63
+ };
64
+ }
65
+
66
+ function matchesResolvedScope(left: ResolvedScopeV1, right: ResolvedScopeV1): boolean {
67
+ return JSON.stringify(left) === JSON.stringify(right);
68
+ }
69
+
70
+ /** Retains a row prefix only when its final escaped prompt block fits the budget. */
71
+ function boundSnapshotRows(
72
+ snapshot: Omit<MasterPeerSnapshot, "rows" | "truncated">,
73
+ rows: readonly SdkSearchRowV1[],
74
+ ): {
75
+ rows: readonly SdkSearchRowV1[];
76
+ truncated: boolean;
77
+ } {
78
+ const retained: SdkSearchRowV1[] = [];
79
+ for (const [index, row] of rows.entries()) {
80
+ if (retained.length >= MASTER_PEER_SNAPSHOT_MAX_ROWS) return { rows: retained, truncated: true };
81
+
82
+ retained.push(row);
83
+ const willTruncate = index < rows.length - 1;
84
+ const candidate: MasterPeerSnapshot = {
85
+ ...snapshot,
86
+ rows: retained,
87
+ ...(willTruncate ? { truncated: true } : {}),
88
+ };
89
+ if (Buffer.byteLength(renderMasterPeerSnapshot(candidate), "utf8") > MASTER_PEER_SNAPSHOT_MAX_BYTES) {
90
+ retained.pop();
91
+ return { rows: retained, truncated: true };
92
+ }
93
+ if (willTruncate && retained.length === MASTER_PEER_SNAPSHOT_MAX_ROWS) return { rows: retained, truncated: true };
94
+ }
95
+ return { rows: retained, truncated: false };
96
+ }
97
+
98
+ /**
99
+ * Collects the first-request peer snapshot through scoped Broker session.list.
100
+ * It deliberately has no Router or endpoint-probe dependency.
101
+ */
102
+ export async function collectMasterPeerSnapshot(input: CollectMasterPeerSnapshotInput): Promise<MasterPeerSnapshot> {
103
+ const request: ScopeRequestV1 = {
104
+ version: 1,
105
+ requested: input.scope,
106
+ requestAnchor: input.requestAnchor,
107
+ };
108
+ const resolvedScope = await resolveScopeRequest(request);
109
+ const outcome = await input.lifecycle.list({
110
+ actor: input.actor,
111
+ capability: "session.list",
112
+ target: { scope: request },
113
+ ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
114
+ });
115
+ const result = sdkSearchResultV1(outcome.result);
116
+ if (!result || !matchesResolvedScope(result.scope, resolvedScope)) return unavailableSnapshot(resolvedScope);
117
+ const snapshot: Omit<MasterPeerSnapshot, "rows" | "truncated"> = {
118
+ status: result.status,
119
+ scope: result.scope,
120
+ observedAt: result.observedAt,
121
+ ...(result.indexSeq === undefined ? {} : { indexSeq: result.indexSeq }),
122
+ };
123
+ const rows = result.rows
124
+ .filter(row => row.id !== (input.currentSessionId ?? input.ownerSessionId))
125
+ .sort(compareRows);
126
+ const bounded = boundSnapshotRows(snapshot, rows);
127
+ return {
128
+ ...snapshot,
129
+ rows: bounded.rows,
130
+ ...(bounded.truncated ? { truncated: true } : {}),
131
+ };
132
+ }
133
+
134
+ /** Escapes untrusted metadata before it is placed in fixed prompt framing. */
135
+ export function escapeMasterPeerSnapshotText(input: string): string {
136
+ let firstEscapable = -1;
137
+ for (let index = 0; index < input.length; index++) {
138
+ const code = input.charCodeAt(index);
139
+ if (code === 38 || code === 60 || code === 62 || code === 96) {
140
+ firstEscapable = index;
141
+ break;
142
+ }
143
+ }
144
+ if (firstEscapable === -1) return input;
145
+
146
+ let output = input.slice(0, firstEscapable);
147
+ for (let index = firstEscapable; index < input.length; index++) {
148
+ const character = input[index];
149
+ if (character === "&") output += "&amp;";
150
+ else if (character === "<") output += "&lt;";
151
+ else if (character === ">") output += "&gt;";
152
+ else if (character === "`") output += "&#96;";
153
+ else output += character;
154
+ }
155
+ return output;
156
+ }
157
+
158
+ function escapeOptionalText(input: string | null): string | null {
159
+ return input === null ? null : escapeMasterPeerSnapshotText(input);
160
+ }
161
+
162
+ function renderedScope(scope: ResolvedScopeV1): ResolvedScopeV1 {
163
+ const resolved =
164
+ scope.resolved === null
165
+ ? null
166
+ : scope.resolved.kind === "repo"
167
+ ? { kind: "repo" as const, worktreeRoot: escapeMasterPeerSnapshotText(scope.resolved.worktreeRoot) }
168
+ : scope.resolved.kind === "pwd"
169
+ ? { kind: "pwd" as const, cwd: escapeMasterPeerSnapshotText(scope.resolved.cwd) }
170
+ : { kind: "global" as const, visibility: scope.resolved.visibility };
171
+ return {
172
+ version: scope.version,
173
+ requested: escapeMasterPeerSnapshotText(scope.requested) as ScopeNameV1,
174
+ requestAnchor: {
175
+ cwd: escapeMasterPeerSnapshotText(scope.requestAnchor.cwd),
176
+ worktreeRoot: escapeOptionalText(scope.requestAnchor.worktreeRoot),
177
+ },
178
+ resolved,
179
+ resolution: escapeMasterPeerSnapshotText(scope.resolution) as ResolvedScopeV1["resolution"],
180
+ };
181
+ }
182
+
183
+ function renderedRows(rows: readonly SdkSearchRowV1[]): readonly SdkSearchRowV1[] {
184
+ return rows.map(row => ({
185
+ id: escapeMasterPeerSnapshotText(row.id),
186
+ locator: {
187
+ cwd: escapeMasterPeerSnapshotText(row.locator.cwd),
188
+ worktreeRoot: escapeOptionalText(row.locator.worktreeRoot),
189
+ stateRoot: escapeMasterPeerSnapshotText(row.locator.stateRoot),
190
+ },
191
+ live: row.live,
192
+ }));
193
+ }
194
+
195
+ /** Renders the fixed, escaped prompt block for one master peer observation. */
196
+ export function renderMasterPeerSnapshot(snapshot: MasterPeerSnapshot): string {
197
+ const content = {
198
+ scope: renderedScope(snapshot.scope),
199
+ observedAt: escapeMasterPeerSnapshotText(snapshot.observedAt),
200
+ ...(snapshot.indexSeq === undefined ? {} : { indexSeq: snapshot.indexSeq }),
201
+ ...(snapshot.truncated === undefined ? {} : { truncated: snapshot.truncated }),
202
+ rows: renderedRows(snapshot.rows),
203
+ };
204
+ return `<gjc-master-peer-snapshot>\nstatus: ${escapeMasterPeerSnapshotText(snapshot.status)}\n${JSON.stringify(content)}\n</gjc-master-peer-snapshot>`;
205
+ }