@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
@@ -16,6 +16,8 @@ export type NativePublishOperation = "direct_rename" | "retained_file" | "retain
16
16
 
17
17
  export type NativePublishPrimitive =
18
18
  | "renameat2_noreplace"
19
+ | "linkat_noreplace"
20
+ | "mkdirat_renameat_noreplace"
19
21
  | "renameatx_np_excl"
20
22
  | "windows_rename_noreplace"
21
23
  | "unsupported"
@@ -24,6 +26,7 @@ export type NativePublishPhase =
24
26
  | "preflight"
25
27
  | "file_sync"
26
28
  | "rename"
29
+ | "source_unlink"
27
30
  | "source_parent_sync"
28
31
  | "destination_parent_sync"
29
32
  | "terminal_identity"
@@ -48,6 +51,7 @@ export type NativePublishIdentity = {
48
51
  readonly dev: string;
49
52
  readonly ino: string;
50
53
  readonly size: string;
54
+ readonly nlink?: string;
51
55
  readonly mtimeNs: string;
52
56
  readonly ctimeNs: string;
53
57
  readonly sha256?: string;
@@ -82,6 +86,8 @@ const reasons = new Set<NativePublishReason>([
82
86
  ]);
83
87
  const primitives = new Set<NativePublishPrimitive>([
84
88
  "renameat2_noreplace",
89
+ "linkat_noreplace",
90
+ "mkdirat_renameat_noreplace",
85
91
  "renameatx_np_excl",
86
92
  "windows_rename_noreplace",
87
93
  "unsupported",
@@ -91,6 +97,7 @@ const phases = new Set<NativePublishPhase>([
91
97
  "preflight",
92
98
  "file_sync",
93
99
  "rename",
100
+ "source_unlink",
94
101
  "source_parent_sync",
95
102
  "destination_parent_sync",
96
103
  "terminal_identity",
@@ -126,12 +133,13 @@ const malformed: NativePublishOutcome = Object.freeze({
126
133
 
127
134
  function validIdentity(value: unknown): boolean {
128
135
  if (value === undefined) return true;
129
- if (!ownPlainRecord(value) || !exactKeys(value, ["dev", "ino", "size", "mtimeNs", "ctimeNs", "sha256"]))
136
+ if (!ownPlainRecord(value) || !exactKeys(value, ["dev", "ino", "nlink", "size", "mtimeNs", "ctimeNs", "sha256"]))
130
137
  return false;
131
138
  const decimal = (field: unknown) => typeof field === "string" && /^-?[0-9]{1,32}$/.test(field);
132
139
  return (
133
140
  decimal(value.dev) &&
134
141
  decimal(value.ino) &&
142
+ (value.nlink === undefined || decimal(value.nlink)) &&
135
143
  decimal(value.size) &&
136
144
  decimal(value.mtimeNs) &&
137
145
  decimal(value.ctimeNs) &&
@@ -202,7 +210,7 @@ function legalOutcome(outcome: NativePublishOutcome): boolean {
202
210
  ((outcome.reason === "durability_not_provable" &&
203
211
  ["file_sync", "source_parent_sync", "destination_parent_sync"].includes(outcome.phase)) ||
204
212
  (outcome.reason === "identity_violation" && outcome.phase === "terminal_identity") ||
205
- (outcome.reason === "io_failure" && outcome.phase === "terminal_identity"))
213
+ (outcome.reason === "io_failure" && ["source_unlink", "terminal_identity"].includes(outcome.phase)))
206
214
  );
207
215
  }
208
216
 
@@ -245,8 +253,12 @@ export function classifyNativePublishOutcome(
245
253
  const outcome = value as unknown as NativePublishOutcome;
246
254
  if (!legalOutcome(outcome)) return malformed;
247
255
  if (operation === "direct_rename") return outcome;
256
+ const retainedPrimitiveValid =
257
+ operation === "retained_file"
258
+ ? outcome.primitive === "renameat2_noreplace" || outcome.primitive === "linkat_noreplace"
259
+ : outcome.primitive === "renameat2_noreplace" || outcome.primitive === "mkdirat_renameat_noreplace";
248
260
  if (
249
- outcome.primitive !== "renameat2_noreplace" ||
261
+ !retainedPrimitiveValid ||
250
262
  (outcome.ok && (!outcome.identity || outcome.durabilityState !== "proven" || outcome.phase !== "complete"))
251
263
  )
252
264
  return malformed;
@@ -0,0 +1,11 @@
1
+ export const SESSION_MIGRATION_BUSY_MESSAGE =
2
+ "Another session migration is still active. Wait for it to finish, then retry.";
3
+
4
+ export class SessionMigrationBusyError extends Error {
5
+ readonly code = "migration_busy";
6
+
7
+ constructor() {
8
+ super(SESSION_MIGRATION_BUSY_MESSAGE);
9
+ this.name = "SessionMigrationBusyError";
10
+ }
11
+ }
@@ -83,8 +83,8 @@ import {
83
83
  mayCleanManagedTreeStaging,
84
84
  retainManagedDirectoryAuthority,
85
85
  } from "./internal/managed-session-storage";
86
-
87
86
  import { classifyNativePublishOutcome, formatNativePublishDiagnostic } from "./internal/native-publish-outcome";
87
+ import { SessionMigrationBusyError } from "./internal/session-open-errors";
88
88
  import {
89
89
  hasOnlyKeys as hasOnlyMemoryGuardKeys,
90
90
  isMemoryGuardDecimalString,
@@ -940,6 +940,8 @@ export class SessionMigrationPolicyError extends Error {
940
940
  }
941
941
 
942
942
  export class SessionArtifactCapacityError extends Error {
943
+ readonly code = "artifact_capacity_exceeded";
944
+
943
945
  constructor(message: string) {
944
946
  super(message);
945
947
  this.name = "SessionArtifactCapacityError";
@@ -954,7 +956,12 @@ export interface StrictSessionOpenSuccess {
954
956
 
955
957
  export interface StrictSessionOpenFailure {
956
958
  kind: "error";
957
- reason: ResumeTailError["reason"] | "identity-mismatch" | "migration-required" | "artifact_capacity_exceeded";
959
+ reason:
960
+ | ResumeTailError["reason"]
961
+ | "identity-mismatch"
962
+ | "migration-required"
963
+ | "artifact_capacity_exceeded"
964
+ | "migration_busy";
958
965
  message?: string;
959
966
  }
960
967
 
@@ -4707,6 +4714,7 @@ export const MATERIALIZED_CACHE_MAX_BYTES = 32 * 1024 * 1024;
4707
4714
  export const SessionManagerTestHooks: {
4708
4715
  materializedCacheMaxBytesOverride?: number;
4709
4716
  beforeResidentTransitionIndexBuild?: () => void;
4717
+ afterForkSnapshot?: () => void | Promise<void>;
4710
4718
  } = {};
4711
4719
 
4712
4720
  function materializedCacheMaxBytes(): number {
@@ -6165,6 +6173,7 @@ export class SessionManager {
6165
6173
  const candidate = listing.owned.find(candidate => path.resolve(candidate.path) === requestedPath);
6166
6174
  if (!candidate) throw new Error("Managed session deletion requires exact logical authorization.");
6167
6175
  const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
6176
+ if (deleted.kind === "error" && deleted.code === "migration_busy") throw new SessionMigrationBusyError();
6168
6177
  if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted")
6169
6178
  throw new Error(`Could not delete managed session: ${deleted.message}`);
6170
6179
  } else {
@@ -6248,6 +6257,8 @@ export class SessionManager {
6248
6257
  newHeader,
6249
6258
  ...materializedEntries.filter((entry): entry is SessionEntry => entry.type !== "session"),
6250
6259
  ];
6260
+ const forkSnapshotHook = SessionManagerTestHooks.afterForkSnapshot;
6261
+ if (forkSnapshotHook) await forkSnapshotHook();
6251
6262
 
6252
6263
  await this.#closePersistWriter();
6253
6264
  this.#persistChain = Promise.resolve();
@@ -9603,6 +9614,7 @@ export class SessionManager {
9603
9614
  authority,
9604
9615
  );
9605
9616
  } catch (error) {
9617
+ if (error instanceof Error && error.message === "migration_busy") throw new SessionMigrationBusyError();
9606
9618
  if (error instanceof Error && error.message.startsWith("Managed root authority changed"))
9607
9619
  managedDestinationStore.assertBound();
9608
9620
  throw error;
@@ -9611,6 +9623,7 @@ export class SessionManager {
9611
9623
  managedDestinationStore.assertBound();
9612
9624
  if (opened.code === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
9613
9625
  if (opened.code === "artifact_capacity_exceeded") throw new SessionArtifactCapacityError(opened.message);
9626
+ if (opened.code === "migration_busy") throw new SessionMigrationBusyError();
9614
9627
  throw new Error(`Could not open managed session: ${opened.message}`);
9615
9628
  }
9616
9629
  assertManagedDestinationBound();
@@ -9706,7 +9719,15 @@ export class SessionManager {
9706
9719
  throw new Error(`Could not open session: ${inspected.reason}`);
9707
9720
  }
9708
9721
  const opened = await SessionManager.openExistingStrict(inspected.identity, destination, storage, migrationPolicy);
9709
- if (opened.kind === "error") throw new Error(`Could not open session: ${opened.reason}`);
9722
+ if (opened.kind === "error") {
9723
+ if (opened.reason === "legacy_migration_disabled") throw new SessionMigrationPolicyError();
9724
+ if (opened.reason === "artifact_capacity_exceeded")
9725
+ throw new SessionArtifactCapacityError(
9726
+ opened.message ?? "Session artifacts exceed the migration capacity.",
9727
+ );
9728
+ if (opened.reason === "migration_busy") throw new SessionMigrationBusyError();
9729
+ throw new Error(`Could not open session: ${opened.reason}`);
9730
+ }
9710
9731
  return opened.manager;
9711
9732
  }
9712
9733
 
@@ -9844,6 +9865,7 @@ export class SessionManager {
9844
9865
  const candidate = listing.owned.find(item => path.resolve(item.path) === path.resolve(sessionPath));
9845
9866
  if (!candidate) throw new Error("Session is not an authorized managed candidate.");
9846
9867
  const deleted = await deleteManagedSessionCandidate(resolved.scope, candidate);
9868
+ if (deleted.kind === "error" && deleted.code === "migration_busy") throw new SessionMigrationBusyError();
9847
9869
  if (deleted.kind !== "deleted" && deleted.kind !== "already_deleted")
9848
9870
  throw new Error(`Could not delete managed session: ${deleted.message}`);
9849
9871
  }
@@ -10389,6 +10411,7 @@ export class SessionManager {
10389
10411
  return { kind: "error", reason: "legacy_migration_disabled" };
10390
10412
  if (error instanceof SessionArtifactCapacityError)
10391
10413
  return { kind: "error", reason: "artifact_capacity_exceeded", message: error.message };
10414
+ if (error instanceof SessionMigrationBusyError) return { kind: "error", reason: "migration_busy" };
10392
10415
  if (
10393
10416
  error instanceof Error &&
10394
10417
  (error.message.includes("source_changed") || error.message.includes("changed before migration"))
@@ -10451,6 +10474,7 @@ export class SessionManager {
10451
10474
  throw new SessionArtifactCapacityError(
10452
10475
  opened.message ?? "Session artifacts exceed the migration capacity.",
10453
10476
  );
10477
+ if (opened.reason === "migration_busy") throw new SessionMigrationBusyError();
10454
10478
  return undefined;
10455
10479
  }
10456
10480
  return opened.manager;
@@ -312,6 +312,7 @@ export type VerifiedSessionDeleteResult =
312
312
  artifactsIdentity: SessionStorageFileIdentity | undefined;
313
313
  /** Identity-bound quarantine path retained when recursive cleanup failed. */
314
314
  detachedArtifactsPath: string;
315
+ artifactsPayloadDurable?: true;
315
316
  /** Native snapshot required for an identity-bound recursive retry. */
316
317
  artifactsTree: NativeDirectoryTreeSnapshot;
317
318
  /** Transcript identity (unchanged) for retry binding. */
@@ -328,6 +329,7 @@ export type VerifiedSessionDeleteResult =
328
329
  transcriptIdentity: SessionStorageFileIdentity;
329
330
  /** Optional identity-bound transcript quarantine path for restart cleanup. */
330
331
  detachedTranscriptPath?: string;
332
+ transcriptPayloadDurable?: true;
331
333
  retainedSuccessorPath?: string;
332
334
  retainedPlaceholderPath?: string;
333
335
  retainedUnknownPath?: string;
@@ -423,6 +425,7 @@ type NativeDirectoryTreeApi = {
423
425
  function nativeDirectoryTreeApi(): NativeDirectoryTreeApi {
424
426
  return native as unknown as NativeDirectoryTreeApi;
425
427
  }
428
+
426
429
  function snapshotDirectoryTree(pathname: string): NativeDirectoryTreeSnapshot {
427
430
  const result = nativeDirectoryTreeApi().snapshotDirectoryTree(pathname);
428
431
  if (!result.ok || !result.snapshot)
@@ -438,10 +441,21 @@ function retainedTreeDoesNotExpandAuthority(
438
441
  ): boolean {
439
442
  if (expected.rootDev !== retained.rootDev || expected.rootIno !== retained.rootIno) return false;
440
443
  const expectedEntries = new Map(expected.entries.map(entry => [entry.relativePath, entry]));
444
+ if (retained.entries.length > expected.entries.length) return false;
441
445
  return retained.entries.every(entry => {
442
446
  if (entry.relativePath === "") return entry.kind === "directory";
443
447
  const authorized = expectedEntries.get(entry.relativePath);
444
- return authorized !== undefined && JSON.stringify(authorized) === JSON.stringify(entry);
448
+ if (
449
+ authorized === undefined ||
450
+ authorized.kind !== entry.kind ||
451
+ authorized.dev !== entry.dev ||
452
+ authorized.ino !== entry.ino ||
453
+ authorized.nlink !== entry.nlink
454
+ )
455
+ return false;
456
+ if (entry.kind !== "file") return entry.size === authorized.size;
457
+ const scrubbed = entry.size === "0" && entry.sha256 === createHash("sha256").update("").digest("hex");
458
+ return scrubbed || (entry.size === authorized.size && entry.sha256 === authorized.sha256);
445
459
  });
446
460
  }
447
461
 
@@ -938,7 +952,7 @@ export class FileSessionStorage implements SessionStorage {
938
952
  "Transcript parent identity does not match authorization",
939
953
  );
940
954
  const authorizedTranscriptParentIdentity = transcriptParentIdentity ?? parentIdentity;
941
- if (detachedArtifactsPath) {
955
+ if (detachedArtifactsPath && !artifactsRemoved) {
942
956
  if (
943
957
  !expectedArtifactsIdentity ||
944
958
  path.dirname(detachedArtifactsPath) !== path.dirname(transcriptPath) ||
@@ -997,6 +1011,9 @@ export class FileSessionStorage implements SessionStorage {
997
1011
  artifactsIdentity: expectedArtifactsIdentity,
998
1012
  detachedArtifactsPath: retainedRoot,
999
1013
  artifactsTree: retainedTree,
1014
+ ...((removal as typeof removal & { payloadDurable?: boolean }).payloadDurable === true
1015
+ ? { artifactsPayloadDurable: true as const }
1016
+ : {}),
1000
1017
  ...((removal.retainedSuccessorPath ?? retainedArtifactsSuccessorPath)
1001
1018
  ? { retainedSuccessorPath: removal.retainedSuccessorPath ?? retainedArtifactsSuccessorPath }
1002
1019
  : {}),
@@ -1045,6 +1062,24 @@ export class FileSessionStorage implements SessionStorage {
1045
1062
  "Artifact path reappeared after durable artifact-phase completion",
1046
1063
  );
1047
1064
  }
1065
+ if (artifactsRemoved && detachedArtifactsPath && expectedArtifactsIdentity && expectedArtifactsTree) {
1066
+ const retainedIdentity = this.#optionalDirectoryIdentity(detachedArtifactsPath);
1067
+ if (
1068
+ !retainedIdentity ||
1069
+ retainedIdentity.dev !== expectedArtifactsIdentity.dev ||
1070
+ retainedIdentity.ino !== expectedArtifactsIdentity.ino
1071
+ )
1072
+ throw new SessionDeleteVerificationError(
1073
+ "artifacts",
1074
+ "Retained artifact root identity changed before transcript cleanup",
1075
+ );
1076
+ const retainedTree = snapshotDirectoryTree(detachedArtifactsPath);
1077
+ if (!retainedTreeDoesNotExpandAuthority(expectedArtifactsTree, retainedTree))
1078
+ throw new SessionDeleteVerificationError(
1079
+ "artifacts",
1080
+ "Partial artifact cleanup expanded retained tree authority",
1081
+ );
1082
+ }
1048
1083
  if (!artifactsIdentity && expectedArtifactsIdentity && !detachedArtifactsPath && !artifactsRemoved) {
1049
1084
  // Absence at the original path alone is not completion: native recursive removal
1050
1085
  // may retain the planned root or its deterministic `.removing` final-stage root.
@@ -1116,12 +1151,22 @@ export class FileSessionStorage implements SessionStorage {
1116
1151
  throw new Error("parent_changed");
1117
1152
  fs.fsyncSync(descriptor);
1118
1153
  } catch (error) {
1119
- throw new SessionDeleteVerificationError("artifacts", "durability_failed", { cause: toError(error) });
1154
+ return {
1155
+ kind: "cleanup_pending",
1156
+ phase: "artifacts",
1157
+ error: new SessionDeleteVerificationError("artifacts", "durability_failed", {
1158
+ cause: toError(error),
1159
+ }),
1160
+ artifactsIdentity,
1161
+ detachedArtifactsPath: detach.detachedPath,
1162
+ artifactsTree,
1163
+ transcriptIdentity,
1164
+ };
1120
1165
  } finally {
1121
1166
  if (descriptor !== undefined) fs.closeSync(descriptor);
1122
1167
  }
1123
1168
  }
1124
- if (!detach.ok) {
1169
+ if (!detach.ok && process.platform === "win32") {
1125
1170
  return {
1126
1171
  kind: "cleanup_pending",
1127
1172
  phase: "artifacts",
@@ -1132,7 +1177,6 @@ export class FileSessionStorage implements SessionStorage {
1132
1177
  ...(detach.retainedSuccessorPath ? { retainedSuccessorPath: detach.retainedSuccessorPath } : {}),
1133
1178
  ...(detach.retainedPlaceholderPath ? { retainedPlaceholderPath: detach.retainedPlaceholderPath } : {}),
1134
1179
  ...(detach.retainedUnknownPath ? { retainedUnknownPath: detach.retainedUnknownPath } : {}),
1135
-
1136
1180
  transcriptIdentity,
1137
1181
  };
1138
1182
  }
@@ -1173,6 +1217,9 @@ export class FileSessionStorage implements SessionStorage {
1173
1217
  artifactsIdentity,
1174
1218
  detachedArtifactsPath: retainedRoot,
1175
1219
  artifactsTree: retainedTree,
1220
+ ...((removal as typeof removal & { payloadDurable?: boolean }).payloadDurable === true
1221
+ ? { artifactsPayloadDurable: true as const }
1222
+ : {}),
1176
1223
  ...((removal.retainedSuccessorPath ?? retainedArtifactsSuccessorPath)
1177
1224
  ? { retainedSuccessorPath: removal.retainedSuccessorPath ?? retainedArtifactsSuccessorPath }
1178
1225
  : {}),
@@ -1223,6 +1270,13 @@ export class FileSessionStorage implements SessionStorage {
1223
1270
  quarantineName: path.basename(plannedTranscriptPath),
1224
1271
  });
1225
1272
  if (!deletion.ok) {
1273
+ if (
1274
+ deletion.code === "cleanup_pending" &&
1275
+ (deletion as typeof deletion & { payloadDurable?: boolean }).payloadDurable === true &&
1276
+ deletion.retainedSuccessorPath === undefined &&
1277
+ deletion.retainedUnknownPath === undefined
1278
+ )
1279
+ return { kind: "deleted" };
1226
1280
  const error = exactUnlinkFailure(deletion);
1227
1281
  const retainedAuthority =
1228
1282
  deletion.detachedPath ||
@@ -1234,6 +1288,9 @@ export class FileSessionStorage implements SessionStorage {
1234
1288
  kind: "cleanup_pending",
1235
1289
  phase: "transcript",
1236
1290
  error,
1291
+ ...((deletion as typeof deletion & { payloadDurable?: boolean }).payloadDurable === true
1292
+ ? { transcriptPayloadDurable: true as const }
1293
+ : {}),
1237
1294
  transcriptIdentity,
1238
1295
  detachedTranscriptPath: deletion.detachedPath ?? detachedTranscriptPath,
1239
1296
  ...((deletion.retainedSuccessorPath ?? retainedTranscriptSuccessorPath)
@@ -1288,6 +1345,13 @@ export class FileSessionStorage implements SessionStorage {
1288
1345
  quarantineName: path.basename(plannedTranscriptPath),
1289
1346
  });
1290
1347
  if (!deletion.ok) {
1348
+ if (
1349
+ deletion.code === "cleanup_pending" &&
1350
+ (deletion as typeof deletion & { payloadDurable?: boolean }).payloadDurable === true &&
1351
+ deletion.retainedSuccessorPath === undefined &&
1352
+ deletion.retainedUnknownPath === undefined
1353
+ )
1354
+ return { kind: "deleted" };
1291
1355
  const error = exactUnlinkFailure(deletion);
1292
1356
  const retainedAuthority =
1293
1357
  deletion.detachedPath ||
@@ -1299,6 +1363,9 @@ export class FileSessionStorage implements SessionStorage {
1299
1363
  kind: "cleanup_pending",
1300
1364
  phase: "transcript",
1301
1365
  error,
1366
+ ...((deletion as typeof deletion & { payloadDurable?: boolean }).payloadDurable === true
1367
+ ? { transcriptPayloadDurable: true as const }
1368
+ : {}),
1302
1369
  transcriptIdentity,
1303
1370
  detachedTranscriptPath: deletion.detachedPath,
1304
1371
  ...((deletion.retainedSuccessorPath ?? retainedTranscriptSuccessorPath)
@@ -29,6 +29,8 @@ import {
29
29
  canApplyComposerSubmission,
30
30
  type InteractiveModeContext,
31
31
  } from "../modes/types";
32
+ import { ChatDaemonController } from "../sdk/bus/chat-daemon-control";
33
+ import type { NotificationProvider } from "../sdk/bus/config";
32
34
  import {
33
35
  buildNotificationStatusReport,
34
36
  checkNotificationHealth,
@@ -39,6 +41,7 @@ import {
39
41
  recoverNotifications,
40
42
  sendNotificationTest,
41
43
  } from "../sdk/bus/notification-service";
44
+ import { TelegramDaemonController } from "../sdk/bus/telegram-daemon-control";
42
45
  import { computeCacheMissCostSummary, formatCacheMissSummaryLines } from "../session/cache-economics";
43
46
  import { formatModelOnboardingGuidance } from "../setup/model-onboarding-guidance";
44
47
  import {
@@ -414,6 +417,9 @@ function modelSelectionUsage(runtime: SlashCommandRuntime, currentModelLine?: st
414
417
  .join("\n\n");
415
418
  }
416
419
 
420
+ /** Opt into the paste-a-code OAuth login for browsers that cannot reach this machine. */
421
+ const MANUAL_LOGIN_FLAG = "--manual";
422
+
417
423
  const EFFORT_COMMAND_INPUT_HINT = "[inherit|off|minimal|low|medium|high|xhigh|max]";
418
424
  const EFFORT_COMMAND_ACCEPTED_VALUES = ["inherit", "off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
419
425
 
@@ -498,6 +504,48 @@ function buildChangelogCommandOutput(showFull: boolean): string {
498
504
  return `${title}\n\n${changelogMarkdown}${hint}`;
499
505
  }
500
506
 
507
+ type NotifyServiceArgs = { provider?: NotificationProvider; probe: boolean; message?: string } | { error: string };
508
+
509
+ function isNotificationProvider(value: string): value is NotificationProvider {
510
+ return value === "telegram" || value === "discord" || value === "slack";
511
+ }
512
+
513
+ function parseNotifyServiceArgs(input: string, allowMessage: boolean): NotifyServiceArgs {
514
+ const tokens = input.trim().split(/\s+/).filter(Boolean);
515
+ let provider: NotificationProvider | undefined;
516
+ let probe = false;
517
+ const message: string[] = [];
518
+ for (let index = 0; index < tokens.length; index += 1) {
519
+ const token = tokens[index]!;
520
+ if (token === "--probe") {
521
+ if (allowMessage) return { error: "--probe is valid only for /notify health." };
522
+ probe = true;
523
+ continue;
524
+ }
525
+ if (token === "--provider" || token.startsWith("--provider=")) {
526
+ const value = token === "--provider" ? tokens[++index] : token.slice("--provider=".length);
527
+ if (!value || !isNotificationProvider(value)) {
528
+ return { error: "--provider must be telegram, discord, or slack." };
529
+ }
530
+ if (provider && provider !== value) return { error: "Conflicting notification providers were supplied." };
531
+ provider = value;
532
+ continue;
533
+ }
534
+ if (!provider && message.length === 0 && isNotificationProvider(token)) {
535
+ provider = token;
536
+ continue;
537
+ }
538
+ if (token.startsWith("--")) return { error: `Unknown notification option: ${token}` };
539
+ message.push(token);
540
+ }
541
+ if (!allowMessage && message.length > 0) return { error: "Health accepts only a provider and --probe." };
542
+ return {
543
+ ...(provider ? { provider } : {}),
544
+ probe,
545
+ ...(message.length > 0 ? { message: message.join(" ") } : {}),
546
+ };
547
+ }
548
+
501
549
  const shutdownHandlerTui = (_command: ParsedSlashCommand, runtime: TuiSlashCommandRuntime): SlashCommandResult => {
502
550
  runtime.ctx.editor.setText("");
503
551
  void runtime.ctx.shutdown();
@@ -514,8 +562,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
514
562
  { name: "on", description: "Enable notifications for this session" },
515
563
  { name: "off", description: "Disable notifications for this session" },
516
564
  { name: "status", description: "Show notification configuration (no secrets)" },
517
- { name: "health", description: "Config, daemon-ownership and endpoint health" },
518
- { name: "test", description: "Send a test notification", usage: "[message]" },
565
+ {
566
+ name: "health",
567
+ description: "Config, ownership, endpoint, and selected-provider health",
568
+ usage: "[provider] [--probe]",
569
+ },
570
+ { name: "test", description: "Send a test notification", usage: "[provider|--provider provider] [message]" },
519
571
  { name: "recovery", description: "Clear dead-owner locks and stale endpoint files" },
520
572
  { name: "setup", description: "How to pair a Telegram bot (run in a terminal)" },
521
573
  ],
@@ -541,12 +593,41 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
541
593
  await runtime.output(formatNotificationStatusReport(buildNotificationStatusReport(runtime.settings)));
542
594
  return commandConsumed();
543
595
  case "health": {
544
- const report = await checkNotificationHealth({ settings: runtime.settings, stateRoot });
596
+ const parsed = parseNotifyServiceArgs(rest, false);
597
+ if ("error" in parsed) {
598
+ return usage(`Usage: /notify health [telegram|discord|slack] [--probe]\n${parsed.error}`, runtime);
599
+ }
600
+ const report = await checkNotificationHealth({
601
+ settings: runtime.settings,
602
+ stateRoot,
603
+ provider: parsed.provider,
604
+ probe: parsed.probe,
605
+ });
545
606
  await runtime.output(formatNotificationHealthReport(report));
546
607
  return commandConsumed();
547
608
  }
548
609
  case "test": {
549
- const result = await sendNotificationTest({ settings: runtime.settings, text: rest || undefined });
610
+ const parsed = parseNotifyServiceArgs(rest, true);
611
+ if ("error" in parsed) {
612
+ return usage(
613
+ `Usage: /notify test [telegram|discord|slack|--provider provider] [message]\n${parsed.error}`,
614
+ runtime,
615
+ );
616
+ }
617
+ const result = await sendNotificationTest({
618
+ settings: runtime.settings,
619
+ provider: parsed.provider,
620
+ text: parsed.message,
621
+ deps: {
622
+ providerRuntimeStatus: async provider => {
623
+ const status =
624
+ provider === "telegram"
625
+ ? await new TelegramDaemonController(runtime.settings).status()
626
+ : await new ChatDaemonController(runtime.settings, provider).status();
627
+ return status.health === "running" ? "ready" : "inactive";
628
+ },
629
+ },
630
+ });
550
631
  await runtime.output(formatNotificationTestResult(result));
551
632
  return commandConsumed();
552
633
  }
@@ -1286,20 +1367,45 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1286
1367
  {
1287
1368
  name: "login",
1288
1369
  description: "Login with OAuth provider",
1289
- inlineHint: "[provider|redirect URL]",
1370
+ inlineHint: "[provider|redirect URL] [--manual]",
1290
1371
  allowArgs: true,
1291
1372
  handleTui: (command, runtime) => {
1292
1373
  const manualInput = runtime.ctx.oauthManualInput;
1293
1374
  const args = command.args.trim();
1375
+ const pendingLoginMessage = (): string => {
1376
+ const pendingProvider = manualInput.pendingProviderId;
1377
+ return pendingProvider
1378
+ ? `OAuth login already in progress for ${pendingProvider}. Paste the redirect URL with /login <url>.`
1379
+ : "OAuth login already in progress. Paste the redirect URL with /login <url>.";
1380
+ };
1294
1381
  if (args.length > 0) {
1382
+ const tokens = args.split(/\s+/);
1383
+ // `--manual` is resolved before the paste fallback below, otherwise
1384
+ // `/login anthropic --manual` would be submitted as an authorization code.
1385
+ if (tokens.includes(MANUAL_LOGIN_FLAG)) {
1386
+ const rest = tokens.filter(token => token !== MANUAL_LOGIN_FLAG);
1387
+ const requestedProvider = rest.length === 1 ? rest[0] : undefined;
1388
+ const manualProvider = requestedProvider
1389
+ ? getOAuthProviders().find(provider => provider.id === requestedProvider)
1390
+ : undefined;
1391
+ if (!manualProvider) {
1392
+ runtime.ctx.showWarning(`Usage: /login <provider> ${MANUAL_LOGIN_FLAG}`);
1393
+ runtime.ctx.editor.setText("");
1394
+ return;
1395
+ }
1396
+ if (manualInput.hasPending()) {
1397
+ runtime.ctx.showWarning(pendingLoginMessage());
1398
+ runtime.ctx.editor.setText("");
1399
+ return;
1400
+ }
1401
+ void runtime.ctx.showOAuthSelector("login", manualProvider.id, { manualCode: true });
1402
+ runtime.ctx.editor.setText("");
1403
+ return;
1404
+ }
1295
1405
  const matchedProvider = getOAuthProviders().find(provider => provider.id === args);
1296
1406
  if (matchedProvider) {
1297
1407
  if (manualInput.hasPending()) {
1298
- const pendingProvider = manualInput.pendingProviderId;
1299
- const message = pendingProvider
1300
- ? `OAuth login already in progress for ${pendingProvider}. Paste the redirect URL with /login <url>.`
1301
- : "OAuth login already in progress. Paste the redirect URL with /login <url>.";
1302
- runtime.ctx.showWarning(message);
1408
+ runtime.ctx.showWarning(pendingLoginMessage());
1303
1409
  runtime.ctx.editor.setText("");
1304
1410
  return;
1305
1411
  }
@@ -1318,11 +1424,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1318
1424
  }
1319
1425
 
1320
1426
  if (manualInput.hasPending()) {
1321
- const provider = manualInput.pendingProviderId;
1322
- const message = provider
1323
- ? `OAuth login already in progress for ${provider}. Paste the redirect URL with /login <url>.`
1324
- : "OAuth login already in progress. Paste the redirect URL with /login <url>.";
1325
- runtime.ctx.showWarning(message);
1427
+ runtime.ctx.showWarning(pendingLoginMessage());
1326
1428
  runtime.ctx.editor.setText("");
1327
1429
  return;
1328
1430
  }