@gajae-code/coding-agent 0.12.7 → 0.12.10

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 (117) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/types/cli/notify-cli.d.ts +1 -0
  3. package/dist/types/commands/notify.d.ts +4 -0
  4. package/dist/types/commands/sdk.d.ts +23 -0
  5. package/dist/types/config/file-lock.d.ts +3 -0
  6. package/dist/types/config/model-discovery-manager.d.ts +6 -1
  7. package/dist/types/config/model-registry.d.ts +2 -0
  8. package/dist/types/config/resolve-config-value.d.ts +2 -1
  9. package/dist/types/config/settings-schema.d.ts +17 -1
  10. package/dist/types/discovery/agents-md.d.ts +11 -1
  11. package/dist/types/main.d.ts +1 -0
  12. package/dist/types/modes/components/hook-selector.d.ts +20 -0
  13. package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
  14. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  15. package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
  16. package/dist/types/modes/types.d.ts +6 -0
  17. package/dist/types/sdk/broker/broker.d.ts +5 -1
  18. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  20. package/dist/types/sdk/bus/config.d.ts +75 -113
  21. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  22. package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
  23. package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
  24. package/dist/types/sdk/bus/index.d.ts +1 -1
  25. package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
  26. package/dist/types/sdk/bus/notification-service.d.ts +54 -4
  27. package/dist/types/sdk/bus/session-control.d.ts +8 -3
  28. package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
  29. package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  31. package/dist/types/sdk/client/discovery.d.ts +2 -1
  32. package/dist/types/sdk/host/query/handlers.d.ts +2 -0
  33. package/dist/types/sdk/index.d.ts +1 -0
  34. package/dist/types/sdk/providers.d.ts +22 -0
  35. package/dist/types/sdk/session-directory.d.ts +1 -1
  36. package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
  37. package/dist/types/session/internal/managed-session-storage.d.ts +19 -1
  38. package/dist/types/session/internal/native-publish-outcome.d.ts +3 -2
  39. package/dist/types/session/internal/session-open-errors.d.ts +5 -0
  40. package/dist/types/session/session-manager.d.ts +3 -1
  41. package/dist/types/session/session-storage.d.ts +2 -0
  42. package/dist/types/system-prompt.d.ts +12 -1
  43. package/dist/types/tools/composer-bash-policy.d.ts +9 -1
  44. package/package.json +10 -8
  45. package/scripts/build-sdk-package-smoke.ts +51 -0
  46. package/scripts/capture-notifications-settings-showcase.ts +1 -1
  47. package/scripts/verify-gjc-sdk-canonicalization.ts +14 -1
  48. package/src/cli/notify-cli.ts +215 -99
  49. package/src/commands/notify.ts +19 -8
  50. package/src/commands/sdk.ts +54 -0
  51. package/src/config/file-lock.ts +6 -0
  52. package/src/config/model-discovery-manager.ts +50 -8
  53. package/src/config/model-profiles.ts +8 -0
  54. package/src/config/model-registry.ts +866 -76
  55. package/src/config/resolve-config-value.ts +10 -8
  56. package/src/config/settings-schema.ts +25 -15
  57. package/src/config/settings.ts +15 -15
  58. package/src/discovery/agents-md.ts +76 -19
  59. package/src/gjc-runtime/psmux-detect.ts +12 -16
  60. package/src/gjc-runtime/session-state-sidecar.ts +12 -2
  61. package/src/gjc-runtime/tmux-sessions.ts +8 -1
  62. package/src/internal-urls/docs-index.generated.ts +10 -9
  63. package/src/main.ts +43 -7
  64. package/src/modes/DESIGN.md +11 -11
  65. package/src/modes/components/extensions/inspector-panel.ts +9 -5
  66. package/src/modes/components/hook-selector.ts +155 -44
  67. package/src/modes/components/model-selector.ts +242 -49
  68. package/src/modes/components/notifications-settings-editor.ts +631 -85
  69. package/src/modes/controllers/extension-ui-controller.ts +114 -15
  70. package/src/modes/controllers/selector-controller.ts +525 -93
  71. package/src/modes/print-mode.ts +3 -0
  72. package/src/modes/types.ts +6 -0
  73. package/src/prompts/system/system-prompt.md +1 -0
  74. package/src/sdk/broker/broker.ts +54 -10
  75. package/src/sdk/broker/lifecycle-ledger.ts +34 -1
  76. package/src/sdk/broker/lifecycle.ts +47 -50
  77. package/src/sdk/bus/chat-daemon-cli.ts +12 -5
  78. package/src/sdk/bus/chat-daemon-control.ts +35 -6
  79. package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
  80. package/src/sdk/bus/config.ts +534 -286
  81. package/src/sdk/bus/daemon-paths.ts +2 -0
  82. package/src/sdk/bus/discord-live-provider.ts +57 -2
  83. package/src/sdk/bus/discord-provider.ts +21 -0
  84. package/src/sdk/bus/index.ts +97 -35
  85. package/src/sdk/bus/notification-orchestration.ts +371 -68
  86. package/src/sdk/bus/notification-service.ts +571 -137
  87. package/src/sdk/bus/session-control.ts +119 -44
  88. package/src/sdk/bus/slack-live-provider.ts +78 -2
  89. package/src/sdk/bus/slack-provider.ts +24 -0
  90. package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
  91. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  92. package/src/sdk/bus/telegram-daemon-control.ts +3 -3
  93. package/src/sdk/bus/telegram-daemon.ts +23 -15
  94. package/src/sdk/client/discovery.ts +10 -4
  95. package/src/sdk/host/query/handlers.ts +20 -1
  96. package/src/sdk/index.ts +1 -0
  97. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  98. package/src/sdk/protocol/operation-registry.ts +5 -2
  99. package/src/sdk/providers.ts +51 -0
  100. package/src/sdk/session-directory.ts +1 -0
  101. package/src/sdk/session.ts +34 -14
  102. package/src/session/agent-session.ts +109 -37
  103. package/src/session/internal/managed-session-scope.ts +981 -450
  104. package/src/session/internal/managed-session-storage.ts +418 -47
  105. package/src/session/internal/native-publish-outcome.ts +15 -3
  106. package/src/session/internal/session-open-errors.ts +11 -0
  107. package/src/session/session-manager.ts +27 -3
  108. package/src/session/session-storage.ts +72 -5
  109. package/src/slash-commands/builtin-registry.ts +117 -15
  110. package/src/system-prompt.ts +28 -15
  111. package/src/task/index.ts +9 -3
  112. package/src/tools/ask.ts +1 -1
  113. package/src/tools/bash.ts +4 -2
  114. package/src/tools/composer-bash-policy.ts +21 -5
  115. package/src/tools/telegram-send.ts +4 -2
  116. package/src/utils/command-args.ts +19 -30
  117. package/src/utils/lang-from-path.ts +2 -4
@@ -198,6 +198,9 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
198
198
  let unsubscribe: (() => void) | undefined;
199
199
 
200
200
  try {
201
+ for (const warning of new Set(session.configWarnings)) {
202
+ await writeStderrAndQuiesce(`Warning: ${warning}\n`);
203
+ }
201
204
  // Emit session header for JSON mode.
202
205
  if (mode === "json") {
203
206
  const header = session.sessionManager.getHeader();
@@ -495,4 +495,10 @@ export interface OAuthSelectorOptions {
495
495
  allowExternalCredentialDiscovery?: boolean;
496
496
  trigger?: "bare-login";
497
497
  externalCredentialDiscover?: CredentialAutoImportOptions["discover"];
498
+ /**
499
+ * Pair by pasting the code the provider displays instead of waiting on the
500
+ * loopback callback. Set by `/login <provider> --manual` for browsers that
501
+ * cannot reach this machine.
502
+ */
503
+ manualCode?: boolean;
498
504
  }
@@ -97,6 +97,7 @@ Do not claim a web search, browser action, integration, or subagent ran without
97
97
  <inputs>
98
98
  - Keep tool inputs concise where possible.
99
99
  - For `path` or path-like fields, prefer relative paths.
100
+ - Write non-ASCII text in tool inputs as literal UTF-8, NEVER as hand-spelled `\uXXXX` escapes, including JSON serialized into a string field (no `ensure_ascii`-style output there). Escapes that are the intended source syntax of the file you are writing — character-class ranges, codepoint bounds — are unaffected.
100
101
  {{#if intentTracing}}
101
102
  - Most tools have a `{{intentField}}` parameter. Fill it with a concise intent in present participle form, 2-6 words, no period, capitalized.
102
103
  {{/if}}
@@ -12,6 +12,7 @@ import {
12
12
  import {
13
13
  BROKER_HEARTBEAT_TTL_MS,
14
14
  type BrokerDiscovery,
15
+ type BrokerPublicationObservation,
15
16
  brokerDiscoveryPath,
16
17
  brokerProcessIncarnation,
17
18
  heartbeatBrokerDiscoveryRetained,
@@ -396,6 +397,14 @@ type BrokerLockSnapshot = {
396
397
 
397
398
  const BROKER_PUBLICATION_CADENCE_MS = 5_000;
398
399
  const BROKER_PUBLICATION_GRACE_MS = 15_000;
400
+ // A broker that cannot observe its own publication is not provably the root, but
401
+ // ambiguity is also not proof of replacement, so it must not be treated as a
402
+ // `lost-root` immediately. It must still be bounded: an indefinitely ambiguous
403
+ // broker stops heartbeating, so peers discover it as stale and spawn replacements
404
+ // while it keeps its port and memory forever. Ambiguity therefore accrues against
405
+ // its own deadline, generous enough to absorb transient filesystem faults and far
406
+ // longer than the loss grace.
407
+ const BROKER_AMBIGUITY_GRACE_MS = 120_000;
399
408
  const BROKER_SETTLEMENT_MS = 2_000;
400
409
  type BrokerPublicationState =
401
410
  | "healthy-owned"
@@ -406,6 +415,8 @@ type BrokerPublicationState =
406
415
  type BrokerStopMode = "owned-root" | "lost-root";
407
416
 
408
417
  const terminalPersistenceHooksForTest = new WeakMap<Broker, () => void>();
418
+ const ambiguityGraceOverridesForTest = new WeakMap<Broker, number>();
419
+ const publicationObservationOverridesForTest = new WeakMap<Broker, BrokerPublicationObservation>();
409
420
 
410
421
  export class Broker {
411
422
  readonly settings: ResolvedBrokerSettings;
@@ -419,6 +430,7 @@ export class Broker {
419
430
  #publication: RetainedBrokerDiscovery | null = null;
420
431
  #publicationState: BrokerPublicationState = "healthy-owned";
421
432
  #lossAt: bigint | null = null;
433
+ #ambiguousAt: bigint | null = null;
422
434
  #stopping = false;
423
435
  #transport: BrokerTransport | null = null;
424
436
  #heartbeatTimer: NodeJS.Timeout | null = null;
@@ -565,6 +577,7 @@ export class Broker {
565
577
  this.#stopping = false;
566
578
  this.#publicationState = "healthy-owned";
567
579
  this.#lossAt = null;
580
+ this.#ambiguousAt = null;
568
581
  await Promise.all([this.ledger.assertSupportedStateVersions(), readBrokerDiscovery(this.settings.agentDir)]);
569
582
  await fs.mkdir(path.dirname(this.#lock), { recursive: true, mode: 0o700 });
570
583
  for (;;) {
@@ -648,16 +661,35 @@ export class Broker {
648
661
  #fence(kind: "suspect-unpublished" | "observation-ambiguous" | "heartbeat-ambiguous"): void {
649
662
  if (this.#publicationState === "stopping") return;
650
663
  this.#publicationState = kind;
651
- if (kind === "suspect-unpublished") this.#lossAt ??= process.hrtime.bigint();
652
- else this.#lossAt = null;
664
+ if (kind === "suspect-unpublished") {
665
+ this.#lossAt ??= process.hrtime.bigint();
666
+ this.#ambiguousAt = null;
667
+ } else {
668
+ this.#lossAt = null;
669
+ this.#ambiguousAt ??= process.hrtime.bigint();
670
+ }
671
+ }
672
+ /**
673
+ * Whether this broker has been unable to confirm it is the published root for
674
+ * longer than the deadline for its current fence. Replacement is proven quickly
675
+ * and ambiguity slowly, but neither may persist indefinitely: a permanently
676
+ * fenced broker never heartbeats, so it is unreachable through discovery while
677
+ * still holding its port and memory.
678
+ */
679
+ #fencedBeyondDeadline(): boolean {
680
+ const now = process.hrtime.bigint();
681
+ if (this.#lossAt !== null && now - this.#lossAt >= BigInt(BROKER_PUBLICATION_GRACE_MS) * 1_000_000n) return true;
682
+ const ambiguityGraceMs = ambiguityGraceOverridesForTest.get(this) ?? BROKER_AMBIGUITY_GRACE_MS;
683
+ return this.#ambiguousAt !== null && now - this.#ambiguousAt >= BigInt(ambiguityGraceMs) * 1_000_000n;
653
684
  }
654
685
  async #watchPublication(writeHeartbeat = true): Promise<void> {
655
686
  if (!this.#publication || this.#publicationState === "stopping") return;
656
687
  let observation: ReturnType<RetainedBrokerDiscovery["observe"]>;
657
688
  try {
658
- observation = this.#publication.observe();
689
+ observation = publicationObservationOverridesForTest.get(this) ?? this.#publication.observe();
659
690
  } catch {
660
691
  this.#fence("observation-ambiguous");
692
+ if (this.#fencedBeyondDeadline()) void this.#complete("lost-root");
661
693
  return;
662
694
  }
663
695
  if (observation === "owned") {
@@ -667,15 +699,12 @@ export class Broker {
667
699
  }
668
700
  this.#publicationState = "healthy-owned";
669
701
  this.#lossAt = null;
702
+ this.#ambiguousAt = null;
670
703
  if (writeHeartbeat) await this.#writeHeartbeat();
671
704
  return;
672
705
  }
673
706
  this.#fence(observation === "ambiguous" ? "observation-ambiguous" : "suspect-unpublished");
674
- if (
675
- this.#lossAt !== null &&
676
- process.hrtime.bigint() - this.#lossAt >= BigInt(BROKER_PUBLICATION_GRACE_MS) * 1_000_000n
677
- )
678
- void this.#complete("lost-root");
707
+ if (this.#fencedBeyondDeadline()) void this.#complete("lost-root");
679
708
  }
680
709
  async #writeHeartbeat(): Promise<void> {
681
710
  if (!this.discovery || !this.#publication || this.#publicationState === "stopping") return;
@@ -691,6 +720,7 @@ export class Broker {
691
720
  }
692
721
  this.#publicationState = "healthy-owned";
693
722
  this.#lossAt = null;
723
+ this.#ambiguousAt = null;
694
724
  this.discovery = { ...this.discovery, heartbeatAt };
695
725
  }
696
726
  async heartbeat(): Promise<void> {
@@ -938,9 +968,8 @@ export class Broker {
938
968
  });
939
969
  if (isCleanupPending(response)) return response;
940
970
  const persisted = await this.ledger.readTerminal(identity, requestHash);
941
- const expectedResponseDigest = createHash("sha256").update(canonicalJson(response)).digest("hex");
942
971
  const persistenceVerified =
943
- persisted?.responseDigest === expectedResponseDigest &&
972
+ persisted !== undefined &&
944
973
  canonicalJson(persisted.response) === canonicalJson(response) &&
945
974
  canonicalJson(persisted.durableEffects) === canonicalJson(outcome.durableEffects) &&
946
975
  canonicalJson(persisted.startupFailure) === canonicalJson(outcome.startupFailure);
@@ -972,3 +1001,18 @@ export function setTerminalPersistenceHookForTest(broker: Broker, hook: (() => v
972
1001
  if (hook) terminalPersistenceHooksForTest.set(broker, hook);
973
1002
  else terminalPersistenceHooksForTest.delete(broker);
974
1003
  }
1004
+
1005
+ /** Test-only hook for shortening the bounded ambiguity deadline. */
1006
+ export function setAmbiguityGraceForTest(broker: Broker, graceMs: number | undefined): void {
1007
+ if (graceMs === undefined) ambiguityGraceOverridesForTest.delete(broker);
1008
+ else ambiguityGraceOverridesForTest.set(broker, graceMs);
1009
+ }
1010
+
1011
+ /** Test-only hook for forcing the observation the publication watchdog sees. */
1012
+ export function setPublicationObservationForTest(
1013
+ broker: Broker,
1014
+ observation: BrokerPublicationObservation | undefined,
1015
+ ): void {
1016
+ if (observation === undefined) publicationObservationOverridesForTest.delete(broker);
1017
+ else publicationObservationOverridesForTest.set(broker, observation);
1018
+ }
@@ -666,7 +666,13 @@ export class LifecycleLedger {
666
666
  return this.#mutate(async () => {
667
667
  const previous = this.#byIdentity.get(identity);
668
668
  if (!previous) throw new Error("Unknown lifecycle identity");
669
- const next = { ...previous, ...fields, state, ts: Date.now() };
669
+ const next = {
670
+ ...previous,
671
+ ...fields,
672
+ state,
673
+ ts: Date.now(),
674
+ ...(fields.response !== undefined ? { response: fields.response } : {}),
675
+ };
670
676
  if (this.#isCleanupPending(next)) {
671
677
  next.unresolvedCleanupResponse = undefined;
672
678
  next.unresolvedCleanupResponseDigest = undefined;
@@ -796,4 +802,31 @@ export class LifecycleLedger {
796
802
  get(identity: string): LifecycleLedgerEntry | undefined {
797
803
  return this.#byIdentity.get(identity);
798
804
  }
805
+
806
+ findPendingCleanupByTarget(
807
+ sessionId: string,
808
+ cwd: string,
809
+ transcriptPath: string,
810
+ ): LifecycleLedgerEntry | undefined {
811
+ const expectedCwd = path.resolve(cwd);
812
+ const expectedTranscript = path.resolve(transcriptPath);
813
+ let latest: LifecycleLedgerEntry | undefined;
814
+ for (const entry of this.#byIdentity.values()) {
815
+ if (entry.state !== "effect_started" || !this.#isCleanupPending(entry)) continue;
816
+ const response = entry.response as {
817
+ error?: { cleanup?: { sessionId?: unknown; cwd?: unknown; transcriptPath?: unknown } };
818
+ };
819
+ const cleanup = response.error?.cleanup;
820
+ if (
821
+ cleanup?.sessionId !== sessionId ||
822
+ typeof cleanup.cwd !== "string" ||
823
+ typeof cleanup.transcriptPath !== "string" ||
824
+ path.resolve(cleanup.cwd) !== expectedCwd ||
825
+ path.resolve(cleanup.transcriptPath) !== expectedTranscript
826
+ )
827
+ continue;
828
+ if (!latest || entry.ts > latest.ts) latest = entry;
829
+ }
830
+ return latest;
831
+ }
799
832
  }
@@ -865,7 +865,7 @@ export async function writeSessionLifecycleReady(root: string, id: string, effec
865
865
  const incarnation = processIncarnation(process.pid);
866
866
  if (!incarnation) throw new Error("Lifecycle child has no readable OS incarnation.");
867
867
  await fs.mkdir(path.join(root, "sdk"), { recursive: true, mode: 0o700 });
868
- await fs.writeFile(lifecycleReadyPath(root, id), JSON.stringify({ pid: process.pid, effectMarker, incarnation }), {
868
+ await fs.writeFile(lifecycleReadyPath(root, id), canonicalJson({ pid: process.pid, effectMarker, incarnation }), {
869
869
  mode: 0o600,
870
870
  });
871
871
  }
@@ -1365,15 +1365,6 @@ function lifecycleMetadataReplayFiles(cleanup: CleanupEvidence): LifecycleCleanu
1365
1365
  return cleanup.lifecycleFiles?.length ? cleanup.lifecycleFiles : undefined;
1366
1366
  }
1367
1367
 
1368
- function absentLifecyclePath(file: string): boolean {
1369
- try {
1370
- fsSync.lstatSync(file);
1371
- return false;
1372
- } catch (error) {
1373
- return (error as NodeJS.ErrnoException).code === "ENOENT";
1374
- }
1375
- }
1376
-
1377
1368
  function isLifecycleCleanupResponse(value: LifecycleFileCapture | BrokerResponse | undefined): value is BrokerResponse {
1378
1369
  return typeof value === "object" && value !== null && "ok" in value;
1379
1370
  }
@@ -1423,6 +1414,19 @@ function validateLifecycleMetadataReplay(cleanup: CleanupEvidence): BrokerRespon
1423
1414
  try {
1424
1415
  current = captureLifecycleFile(candidate, true, true);
1425
1416
  } catch {
1417
+ if (
1418
+ file.completed &&
1419
+ [file.detachedPath, file.plannedPath].some(
1420
+ bound => bound && path.resolve(candidate) === path.resolve(bound),
1421
+ )
1422
+ ) {
1423
+ try {
1424
+ const stat = fsSync.lstatSync(candidate);
1425
+ if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1 && stat.size === 0) continue;
1426
+ } catch (error) {
1427
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
1428
+ }
1429
+ }
1426
1430
  return fail("terminal_uncertain", "Lifecycle metadata candidate could not be safely inspected.");
1427
1431
  }
1428
1432
  if (!current) continue;
@@ -1457,7 +1461,7 @@ function validateLifecycleMetadataReplay(cleanup: CleanupEvidence): BrokerRespon
1457
1461
  if (!markerEntry)
1458
1462
  return fail("terminal_uncertain", "Lifecycle readiness metadata lacks canonical marker authority.");
1459
1463
  if (!marker) {
1460
- if (createHash("sha256").update(canonicalJson(readyMarker)).digest("hex") !== markerEntry.identity.sha256)
1464
+ if (createHash("sha256").update(ready.bytes).digest("hex") !== markerEntry.identity.sha256)
1461
1465
  return fail(
1462
1466
  "terminal_uncertain",
1463
1467
  "Lifecycle readiness metadata is not bound to the completed marker authority.",
@@ -1473,30 +1477,6 @@ function validateLifecycleMetadataReplay(cleanup: CleanupEvidence): BrokerRespon
1473
1477
  return undefined;
1474
1478
  }
1475
1479
 
1476
- function lifecycleMetadataReplayAbsent(cleanup: CleanupEvidence): boolean {
1477
- const files = lifecycleMetadataReplayFiles(cleanup);
1478
- if (!files) return true;
1479
- const root = path.resolve(cleanup.metadataRoot!);
1480
- const id = cleanup.sessionId!;
1481
- const required = new Set<string>([lifecycleMarkerPath(root, id), lifecycleReadyPath(root, id)]);
1482
- for (const file of files) for (const candidate of lifecycleCleanupCandidates(file)) required.add(candidate);
1483
- for (const candidate of required) {
1484
- if (absentLifecyclePath(candidate)) continue;
1485
- // A completed file's recorded retained quarantine is durable evidence, not a
1486
- // survivor — accept it only at its receipt-bound path and identity.
1487
- const owner = files.find(
1488
- file =>
1489
- file.completed === true &&
1490
- file.detachedPath !== undefined &&
1491
- path.resolve(file.detachedPath) === path.resolve(candidate),
1492
- );
1493
- if (!owner) return false;
1494
- const current = captureLifecycleFile(candidate, true, true);
1495
- if (!current || !sameLifecycleCleanupIdentity(current.identity, owner.identity)) return false;
1496
- }
1497
- return true;
1498
- }
1499
-
1500
1480
  /**
1501
1481
  * Base dev persisted metadata cleanup one file at a time. Accept only its
1502
1482
  * identity-bound marker receipt and translate it into the current replay plan.
@@ -1612,7 +1592,7 @@ function legacyMetadataCleanupPlan(cleanup: CleanupEvidence): CleanupEvidence |
1612
1592
  return undefined;
1613
1593
  }
1614
1594
  if (marker && !sameEffectMarker(marker, readyMarker)) return undefined;
1615
- if (!marker && createHash("sha256").update(canonicalJson(readyMarker)).digest("hex") !== persistedIdentity.sha256)
1595
+ if (!marker && createHash("sha256").update(ready.capture.bytes).digest("hex") !== persistedIdentity.sha256)
1616
1596
  return undefined;
1617
1597
  }
1618
1598
 
@@ -1785,11 +1765,11 @@ async function reconcileLifecycleCleanup(
1785
1765
  file.detachedPath &&
1786
1766
  path.resolve(candidate) === path.resolve(file.detachedPath) &&
1787
1767
  stat.isFile() &&
1788
- !stat.isSymbolicLink()
1789
- ) {
1790
- const current = captureLifecycleFile(candidate, true, true);
1791
- if (current && sameLifecycleCleanupIdentity(current.identity, file.identity)) continue;
1792
- }
1768
+ !stat.isSymbolicLink() &&
1769
+ stat.nlink === 1 &&
1770
+ stat.size === 0
1771
+ )
1772
+ continue;
1793
1773
  return fail(
1794
1774
  "terminal_uncertain",
1795
1775
  "Lifecycle cleanup receipt marks a target complete while an authorized candidate remains.",
@@ -1892,7 +1872,7 @@ async function reconcileLifecycleCleanup(
1892
1872
  });
1893
1873
  }
1894
1874
  const lifecycleFiles = activeCleanup.lifecycleFiles!.map((candidate, candidateIndex) =>
1895
- candidateIndex === index ? { ...candidate, completed: true as const } : candidate,
1875
+ candidateIndex === index ? { ...candidate, detachedPath: undefined, completed: true as const } : candidate,
1896
1876
  );
1897
1877
  activeCleanup = { ...activeCleanup, lifecycleFiles };
1898
1878
  await broker.ledger.transition(identity, "effect_started", {
@@ -1900,8 +1880,6 @@ async function reconcileLifecycleCleanup(
1900
1880
  });
1901
1881
  lifecycleCleanupHooksForTest.get(broker)?.();
1902
1882
  }
1903
- if (!lifecycleMetadataReplayAbsent(activeCleanup))
1904
- return fail("terminal_uncertain", "Lifecycle metadata replay left an authorized sibling behind.");
1905
1883
  await syncDirectory(path.join(activeCleanup.metadataRoot!, "sdk"));
1906
1884
  return completion;
1907
1885
  }
@@ -3463,6 +3441,21 @@ async function executeLifecycleResponse(
3463
3441
  return (error as NodeJS.ErrnoException).code === "ENOENT";
3464
3442
  }
3465
3443
  };
3444
+ try {
3445
+ const currentTranscript = fsSync.lstatSync(cleanupTarget.transcriptPath, { bigint: true });
3446
+ if (
3447
+ currentTranscript.isFile() &&
3448
+ !currentTranscript.isSymbolicLink() &&
3449
+ currentTranscript.dev === cleanupTarget.transcriptIdentity.dev &&
3450
+ currentTranscript.ino === cleanupTarget.transcriptIdentity.ino
3451
+ )
3452
+ cleanupTarget.transcriptIdentity = {
3453
+ ...cleanupTarget.transcriptIdentity,
3454
+ nlink: currentTranscript.nlink,
3455
+ };
3456
+ } catch (error) {
3457
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
3458
+ }
3466
3459
  const transcriptParentIdentity = cleanup?.transcriptParentIdentity ?? validated.transcriptParentIdentity;
3467
3460
  const durableArtifactsPlan =
3468
3461
  cleanup?.artifactTree?.plannedPath ?? cleanup?.plannedArtifactsPath ?? cleanupTarget.plannedArtifactsPath;
@@ -3687,11 +3680,17 @@ async function executeLifecycleResponse(
3687
3680
  const retainedTranscriptReplayHasNoSideAuthority = retainedTranscriptSidePaths.every(
3688
3681
  candidate => candidate === undefined,
3689
3682
  );
3690
- const retainedTranscriptSideAuthorityIsProvenAbsent =
3691
- retainedTranscriptSidePaths.every(pathIsAbsent) &&
3692
- (cleanupTarget.detachedTranscriptPath ? true : retainedTranscriptIdentityIsAbsentFromParent());
3693
3683
  if (cleanup && !retainedTranscriptReplayHasNoSideAuthority) {
3694
- if (!retainedTranscriptSideAuthorityIsProvenAbsent) return await publishRetainedTranscriptSideAuthority();
3684
+ const successorOrUnknownRemains = [
3685
+ cleanupTarget.retainedTranscriptSuccessorPath,
3686
+ cleanupTarget.retainedTranscriptUnknownPath,
3687
+ ].some(candidate => candidate !== undefined && !pathIsAbsent(candidate));
3688
+ if (
3689
+ successorOrUnknownRemains ||
3690
+ !pathIsAbsent(cleanupTarget.retainedTranscriptPlaceholderPath) ||
3691
+ (!cleanupTarget.detachedTranscriptPath && !retainedTranscriptIdentityIsAbsentFromParent())
3692
+ )
3693
+ return await publishRetainedTranscriptSideAuthority();
3695
3694
  cleanupTarget.retainedTranscriptSuccessorPath = undefined;
3696
3695
  cleanupTarget.retainedTranscriptPlaceholderPath = undefined;
3697
3696
  cleanupTarget.retainedTranscriptUnknownPath = undefined;
@@ -3861,8 +3860,6 @@ async function executeLifecycleResponse(
3861
3860
  preauthorizedCleanup,
3862
3861
  "Saved session cleanup is pending in artifacts: a planned quarantine alias remains before terminal completion.",
3863
3862
  );
3864
- if (deleted.kind === "deleted" && !retainedTranscriptIdentityIsAbsentFromParent())
3865
- return await publishRetainedTranscriptSideAuthority();
3866
3863
  const retainedRootArtifactsPlan = durableArtifactsPlan;
3867
3864
  if (deleted.kind === "cleanup_pending")
3868
3865
  return fail(
@@ -11,10 +11,11 @@ import {
11
11
  } from "./chat-daemon-control";
12
12
  import { type ChatDaemonRuntimeConfig, ChatDaemonRuntime as DefaultChatDaemonRuntime } from "./chat-daemon-runtime";
13
13
  import {
14
- isDiscordConfigured,
15
- isSlackConfigured,
14
+ isDiscordComplete,
15
+ isSlackComplete,
16
16
  loadNotificationConfigFile,
17
17
  notificationConfigFromFile,
18
+ resolveNotificationProvider,
18
19
  } from "./config";
19
20
 
20
21
  export interface ChatDaemonRuntimeHandle {
@@ -60,7 +61,10 @@ async function loadConfig(agentDir: string, kind: ChatDaemonKind): Promise<ChatD
60
61
  const config = notificationConfigFromFile(loaded.value);
61
62
  if (!config.enabled) return undefined;
62
63
  if (kind === "discord") {
63
- if (!isDiscordConfigured(config)) {
64
+ const resolution = resolveNotificationProvider(config, "discord");
65
+ if (!resolution.desiredEnabled) return undefined;
66
+ if (resolution.quarantined) throw new Error("Discord notification configuration needs repair");
67
+ if (!resolution.configured || !isDiscordComplete(config)) {
64
68
  throw new Error("Discord notifications are enabled but configuration is incomplete");
65
69
  }
66
70
  const discord = config.discord;
@@ -78,7 +82,10 @@ async function loadConfig(agentDir: string, kind: ChatDaemonKind): Promise<ChatD
78
82
  presentation: { redact: config.redact, verbosity: config.verbosity },
79
83
  };
80
84
  }
81
- if (!isSlackConfigured(config)) {
85
+ const resolution = resolveNotificationProvider(config, "slack");
86
+ if (!resolution.desiredEnabled) return undefined;
87
+ if (resolution.quarantined) throw new Error("Slack notification configuration needs repair");
88
+ if (!resolution.configured || !isSlackComplete(config)) {
82
89
  throw new Error("Slack notifications are enabled but configuration is incomplete");
83
90
  }
84
91
  const slack = config.slack;
@@ -149,7 +156,7 @@ export async function runChatDaemonInternal(
149
156
 
150
157
  let incarnation: string | undefined;
151
158
  let runtime: ChatDaemonRuntimeHandle | undefined;
152
- let interval: ReturnType<typeof setInterval> | undefined;
159
+ let interval: NodeJS.Timeout | number | undefined;
153
160
  let stopping = false;
154
161
  let terminalError: unknown;
155
162
  let runtimeStop: Promise<void> | undefined;
@@ -16,7 +16,7 @@ import type {
16
16
  } from "../../daemon/control-types";
17
17
  import { resolveGjcRuntimeSpawnInfo } from "../../daemon/runtime";
18
18
  import { isProcessIncarnation, processIncarnation } from "../broker/process-incarnation";
19
- import { getNotificationConfig, isDiscordConfigured, isSlackConfigured } from "./config";
19
+ import { getNotificationConfig, isDiscordComplete, isProviderEffectivelyEnabled, isSlackComplete } from "./config";
20
20
 
21
21
  export type ChatDaemonKind = "discord" | "slack";
22
22
  export type ChatDaemonAction = "stop" | "reload";
@@ -40,11 +40,20 @@ export type ChatDaemonAction = "stop" | "reload";
40
40
  * daemon transports. Generation 16 applies Telegram sound-policy configuration
41
41
  * through shared notification parsing. Generation 17 bound managed-session
42
42
  * replacement to exact native filesystem authority; generation 18 retired that
43
- * binding, and generation 19 binds exact cleanup to parent/link-count authority.
43
+ * binding, and generation 19 binds exact cleanup to parent/link-count authority
44
+ * while also adding durable provider-intent admission without changing lifecycle
45
+ * behavior. Generation 20 discovers isolated chat-only session endpoints when
46
+ * Telegram identity ownership is blocked. Discord generation 21 applies rustfmt
47
+ * and clippy-equivalent cleanup to the pi-shell process-tree authority (#3682).
48
+ * Discord generation 22 / slack generation 21 refreshes retained cleanup
49
+ * semantics; discord generation 23 / slack generation 22 hardens exact Bash
50
+ * process-tree ownership shared by chat daemon cleanup.
51
+ * Discord generation 24 / slack generation 23 apply provider-completeness and
52
+ * effective-enable admission to chat daemon lifecycle controls.
44
53
  */
45
54
  export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
46
- discord: 20,
47
- slack: 20,
55
+ discord: 24,
56
+ slack: 23,
48
57
  };
49
58
 
50
59
  export function chatDaemonGeneration(kind: ChatDaemonKind): number {
@@ -271,7 +280,7 @@ export function chatDaemonPaths(
271
280
  function identityFor(settings: Settings, kind: ChatDaemonKind): string | undefined {
272
281
  const cfg = getNotificationConfig(settings);
273
282
  if (kind === "discord") {
274
- if (!isDiscordConfigured(cfg)) return undefined;
283
+ if (!isDiscordComplete(cfg)) return undefined;
275
284
  return fingerprint([
276
285
  cfg.discord.botToken,
277
286
  cfg.discord.applicationId,
@@ -281,7 +290,7 @@ function identityFor(settings: Settings, kind: ChatDaemonKind): string | undefin
281
290
  cfg.verbosity,
282
291
  ]);
283
292
  }
284
- if (!isSlackConfigured(cfg)) return undefined;
293
+ if (!isSlackComplete(cfg)) return undefined;
285
294
  return fingerprint([
286
295
  cfg.slack.botToken,
287
296
  cfg.slack.appToken,
@@ -426,6 +435,9 @@ export class ChatDaemonController implements BuiltInDaemonController {
426
435
  private identity(): string | undefined {
427
436
  return identityFor(this.settings, this.kind);
428
437
  }
438
+ private effectivelyEnabled(): boolean {
439
+ return isProviderEffectivelyEnabled(getNotificationConfig(this.settings), this.kind);
440
+ }
429
441
  private alive(pid: number): boolean {
430
442
  return (this.deps.pidAlive ?? defaultPidAlive)(pid);
431
443
  }
@@ -453,6 +465,7 @@ export class ChatDaemonController implements BuiltInDaemonController {
453
465
  return await this.operate("reload", opts);
454
466
  }
455
467
  async ensure(): Promise<EnsureChatDaemonResult> {
468
+ if (!this.effectivelyEnabled()) return "disabled";
456
469
  const identity = this.identity();
457
470
  if (!identity) return "disabled";
458
471
  const existing = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
@@ -485,6 +498,9 @@ export class ChatDaemonController implements BuiltInDaemonController {
485
498
  const warnings = before.runtime.warning ? [before.runtime.warning] : [];
486
499
  if (!before.configured)
487
500
  return this.result(action, false, `${this.kind} notifications are not configured`, before, before, warnings);
501
+ if (action === "reload" && !this.effectivelyEnabled()) {
502
+ return this.result(action, false, `${this.kind} notifications are not enabled`, before, before, warnings);
503
+ }
488
504
  const state = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
489
505
  const classification = this.classify(state, this.identity());
490
506
  if (classification === "newer")
@@ -520,6 +536,8 @@ export class ChatDaemonController implements BuiltInDaemonController {
520
536
  }
521
537
  if (opts.spawnIfStopped === false)
522
538
  return this.result(action, true, `no running ${this.kind} daemon to reload`, before, before, warnings);
539
+ if (!this.effectivelyEnabled())
540
+ return this.result(action, false, `${this.kind} notifications are not enabled`, before, before, warnings);
523
541
  const spawned = await this.spawn();
524
542
  return this.result(
525
543
  action,
@@ -573,6 +591,15 @@ export class ChatDaemonController implements BuiltInDaemonController {
573
591
  await clearChatDaemonControlRequest(this.settings.getAgentDir(), this.kind, requestId);
574
592
  if (action === "stop")
575
593
  return this.result(action, true, `stopped ${this.kind} daemon`, before, await this.status(), warnings);
594
+ if (!this.effectivelyEnabled())
595
+ return this.result(
596
+ action,
597
+ false,
598
+ `${this.kind} notifications are not enabled`,
599
+ before,
600
+ await this.status(),
601
+ warnings,
602
+ );
576
603
  const spawned = await this.spawn();
577
604
  return this.result(
578
605
  action,
@@ -750,6 +777,7 @@ export class ChatDaemonController implements BuiltInDaemonController {
750
777
  return this.deps.sleep ? this.deps.sleep(ms) : new Promise(resolve => setTimeout(resolve, ms));
751
778
  }
752
779
  private async spawn(): Promise<boolean> {
780
+ if (!this.effectivelyEnabled()) return false;
753
781
  const identity = this.identity();
754
782
  if (!identity) return false;
755
783
  const paths = chatDaemonPaths(this.settings.getAgentDir(), this.kind);
@@ -766,6 +794,7 @@ export class ChatDaemonController implements BuiltInDaemonController {
766
794
  agentDir: this.settings.getAgentDir(),
767
795
  execPath: this.deps.execPath,
768
796
  });
797
+ if (!this.effectivelyEnabled() || this.identity() !== identity) return false;
769
798
  (this.deps.spawn ?? ((command, args, opts) => childProcessSpawn(command, args, opts)))(command, args, {
770
799
  detached: true,
771
800
  stdio: "ignore",
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
2
4
  import { type IndexedSession, SessionIndex } from "../broker/session-index";
3
5
  import { SdkClient, SdkClientError } from "../client/client";
4
6
  import { readSdkBrokerDiscovery, readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
@@ -275,8 +277,20 @@ export class ChatDaemonRuntime {
275
277
  }
276
278
 
277
279
  private async attach(indexed: IndexedSession): Promise<void> {
278
- const endpoint = await readSdkSessionEndpoint(indexed.locator.repo, indexed.sessionId);
279
- if (!endpoint) return;
280
+ const repo = path.resolve(indexed.locator.repo);
281
+ const defaultStateRoot = path.join(repo, ".gjc", "state");
282
+ const indexedStateRoot = path.resolve(indexed.locator.stateRoot);
283
+ const scope =
284
+ indexedStateRoot === defaultStateRoot
285
+ ? "default"
286
+ : indexedStateRoot === path.join(defaultStateRoot, "chat")
287
+ ? "chat"
288
+ : undefined;
289
+ if (!scope || indexed.endpointMtimeMs === undefined) return;
290
+ const endpoint = await readSdkSessionEndpoint(repo, indexed.sessionId, scope);
291
+ if (!endpoint || endpoint.stale) return;
292
+ const endpointStat = await fs.stat(endpoint.path).catch(() => undefined);
293
+ if (!endpointStat || endpointStat.mtimeMs !== indexed.endpointMtimeMs) return;
280
294
  const existing = this.#sessions.get(indexed.sessionId);
281
295
  if (
282
296
  existing &&