@gajae-code/coding-agent 0.11.5 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -865,6 +865,39 @@ function isExplicitlyStoppedDaemonState(state: unknown): state is DaemonState {
865
865
  state.acquisitionId.length > 0,
866
866
  );
867
867
  }
868
+ /**
869
+ * v0.11.4 and earlier generations wrote stopped tombstones before acquisition
870
+ * and incarnation fields existed. A live, reused PID must not turn that durable
871
+ * stop marker into a foreign live owner during an upgrade.
872
+ */
873
+ function isLegacyStoppedDaemonState(state: unknown): boolean {
874
+ const candidate = state as Partial<DaemonState> | undefined;
875
+ return Boolean(
876
+ candidate &&
877
+ Number.isSafeInteger(candidate.pid) &&
878
+ (candidate.pid ?? 0) > 0 &&
879
+ typeof candidate.ownerId === "string" &&
880
+ candidate.ownerId.length > 0 &&
881
+ typeof candidate.tokenFingerprint === "string" &&
882
+ typeof candidate.chatId === "string" &&
883
+ Number.isSafeInteger(candidate.startedAt) &&
884
+ Number.isSafeInteger(candidate.heartbeatAt) &&
885
+ Number.isSafeInteger(candidate.stoppedAt) &&
886
+ (candidate.launcherPid === undefined ||
887
+ (Number.isSafeInteger(candidate.launcherPid) && (candidate.launcherPid ?? 0) > 0)) &&
888
+ Array.isArray(candidate.roots) &&
889
+ candidate.roots.every(root => typeof root === "string") &&
890
+ candidate.version === DAEMON_VERSION &&
891
+ isRecognizedLegacyGeneration(candidate.generation) &&
892
+ candidate.incarnation === undefined &&
893
+ candidate.acquisitionId === undefined &&
894
+ candidate.ownershipPhase === undefined,
895
+ );
896
+ }
897
+
898
+ function isStoppedDaemonState(state: unknown): boolean {
899
+ return isExplicitlyStoppedDaemonState(state) || isLegacyStoppedDaemonState(state);
900
+ }
868
901
 
869
902
  function ownerIdentityMatches(
870
903
  state: Pick<DaemonState, "tokenFingerprint" | "chatId">,
@@ -1016,7 +1049,7 @@ function classifyForeignLiveOwner(input: {
1016
1049
  // Validate before probing so malformed PIDs never reach the liveness source.
1017
1050
  if (!state || !validDaemonPid(state.pid) || !input.pidAlive(state.pid)) return undefined;
1018
1051
  // A stopped tombstone with a canonical acquisition is not a live owner.
1019
- if (isExplicitlyStoppedDaemonState(state) || isLegacyParentDaemonState(state)) return undefined;
1052
+ if (isStoppedDaemonState(state) || isLegacyParentDaemonState(state)) return undefined;
1020
1053
  const currentIncarnation = input.pidIncarnation?.(state.pid) ?? defaultPidIncarnation(state.pid);
1021
1054
  if (
1022
1055
  !hasSafeDaemonStateShape(state) ||
@@ -1156,7 +1189,7 @@ export async function acquireDaemonOwnership(input: {
1156
1189
  if (state && !hasSafeDaemonStateShape(state)) {
1157
1190
  const malformed = state as Partial<DaemonState>;
1158
1191
  if (
1159
- !isExplicitlyStoppedDaemonState(malformed) &&
1192
+ !isStoppedDaemonState(malformed) &&
1160
1193
  validDaemonPid(malformed.pid) &&
1161
1194
  typeof malformed.incarnation === "string" &&
1162
1195
  pidAlive(malformed.pid)
@@ -2010,6 +2043,60 @@ export async function spawnTelegramDaemonOwner(
2010
2043
  return { result: "owner_spawned", acquisition, runtime, warnings: [] };
2011
2044
  }
2012
2045
 
2046
+ /**
2047
+ * Owner-bound reclamation of a confirmed-dead daemon owner's lock, mirroring the
2048
+ * daemon step of `gjc notify recovery`. Removes only the lock of a recorded owner
2049
+ * that is still the same, still-dead process — never a live or superseded owner,
2050
+ * because it re-checks ownership and liveness while holding the same steal-mutex
2051
+ * the daemon's own takeover path uses. Lets a fresh SDK session self-heal a stale
2052
+ * owner (e.g. after a crash, or a `stop --force` that left the record behind)
2053
+ * instead of requiring manual reconciliation. Returns true when a stale lock was
2054
+ * cleared and a spawn retry is worthwhile.
2055
+ */
2056
+ async function reclaimDeadDaemonOwner(input: {
2057
+ settings: Settings;
2058
+ fs?: TelegramDaemonFs;
2059
+ now?: () => number;
2060
+ pidAlive?: (pid: number) => boolean;
2061
+ pidIncarnation?: (pid: number) => string | undefined;
2062
+ }): Promise<boolean> {
2063
+ const fsImpl = input.fs ?? nodeFs;
2064
+ const now = input.now ?? Date.now;
2065
+ const pidAlive = input.pidAlive ?? defaultPidAlive;
2066
+ const pidIncarnation = input.pidIncarnation ?? defaultPidIncarnation;
2067
+ const paths = daemonPaths(input.settings.getAgentDir());
2068
+ const state = await readDaemonState(input.settings, fsImpl);
2069
+ if (!state || pidAlive(state.pid)) return false;
2070
+ const lock = await readOwnershipLock(fsImpl, paths.lock);
2071
+ if (lock.kind === "missing") return false;
2072
+ if (
2073
+ !(await ownershipLockIsReclaimable({ fs: fsImpl, path: paths.lock, lock, now: now(), pidAlive, pidIncarnation }))
2074
+ )
2075
+ return false;
2076
+ const transition = await acquireDaemonTransitionLock({
2077
+ fs: fsImpl,
2078
+ path: paths.steal,
2079
+ pid: process.pid,
2080
+ pidAlive: defaultPidAlive,
2081
+ pidIncarnation: defaultPidIncarnation,
2082
+ });
2083
+ if (!transition) return false;
2084
+ try {
2085
+ const current = await readDaemonState(input.settings, fsImpl);
2086
+ if (!current || current.ownerId !== state.ownerId || current.pid !== state.pid) return false;
2087
+ if (pidAlive(current.pid)) return false;
2088
+ if (!(await daemonTransitionLockIsHeld({ fs: fsImpl, path: paths.steal, lock: transition }))) return false;
2089
+ try {
2090
+ await fsImpl.unlink(paths.lock);
2091
+ return true;
2092
+ } catch {
2093
+ return false;
2094
+ }
2095
+ } finally {
2096
+ await releaseDaemonTransitionLock({ fs: fsImpl, path: paths.steal, lock: transition });
2097
+ }
2098
+ }
2099
+
2013
2100
  /**
2014
2101
  * Ensure a configured daemon owns this session root, preserving ownership safety
2015
2102
  * while exposing whether a #2028 generation handoff was required.
@@ -2063,6 +2150,29 @@ export async function ensureTelegramDaemonRunningDetailed(
2063
2150
  );
2064
2151
  }
2065
2152
  }
2153
+ if (
2154
+ spawned.result === "blocked" &&
2155
+ !spawned.warnings[0]?.includes("provisional") &&
2156
+ (await reclaimDeadDaemonOwner({
2157
+ settings: input.settings,
2158
+ fs: deps.fs,
2159
+ now: deps.now,
2160
+ pidAlive: deps.pidAlive,
2161
+ pidIncarnation: deps.pidIncarnation,
2162
+ }))
2163
+ ) {
2164
+ // A crashed owner (or a `stop --force` that found no live process) can leave a
2165
+ // stale lock behind that blocks re-acquisition. Automate the recovery step so a
2166
+ // fresh session self-heals instead of requiring a manual `gjc notify recovery`.
2167
+ spawned = await withTelegramSetupLease(
2168
+ cfg.botToken,
2169
+ async () =>
2170
+ await spawnTelegramDaemonOwner(
2171
+ { settings: input.settings, roots: [root], tokenFingerprint: fp, chatId: cfg.chatId },
2172
+ deps,
2173
+ ),
2174
+ );
2175
+ }
2066
2176
  if (spawned.result === "blocked") {
2067
2177
  logger.warn(`notifications: failed to ensure Telegram daemon: ${spawned.warnings.join("; ")}`);
2068
2178
  return "blocked_identity";
@@ -254,10 +254,17 @@ function invoke(
254
254
  }
255
255
 
256
256
  function errorResponse(id: string, row: Operation, error: unknown): ControlResponse {
257
- const candidate = error as { code?: unknown; message?: unknown; recovery?: unknown };
257
+ const candidate = error as { code?: unknown; message?: unknown; recovery?: unknown; handoffDocument?: unknown };
258
258
  const code = typeof candidate?.code === "string" ? candidate.code : undefined;
259
259
  const message = typeof candidate?.message === "string" ? candidate.message : "Control operation failed.";
260
- if (error instanceof BusyError) return failure(id, "busy", message);
260
+ // A failed session.handoff is non-destructive and retains the generated
261
+ // document; surface it on the control protocol so external SDK/ACP/daemon
262
+ // clients can copy/retry it, mirroring the in-process seams and TUI.
263
+ const details: ControlValue | undefined =
264
+ row.sdkId === "session.handoff" && typeof candidate?.handoffDocument === "string"
265
+ ? ({ handoffDocument: candidate.handoffDocument } as ControlValue)
266
+ : undefined;
267
+ if (error instanceof BusyError) return failure(id, "busy", message, undefined, details);
261
268
  if (code === "default_model_selection_recovery" && row.errorCodes.includes(code)) {
262
269
  const recovery = parseDefaultModelSelectionRecovery(candidate.recovery) ?? {
263
270
  message: DEFAULT_MODEL_SELECTION_RECOVERY_MESSAGE,
@@ -265,11 +272,13 @@ function errorResponse(id: string, row: Operation, error: unknown): ControlRespo
265
272
  };
266
273
  return failure(id, code, DEFAULT_MODEL_SELECTION_RECOVERY_MESSAGE, undefined, recovery);
267
274
  }
268
- if (code && (row.errorCodes.includes(code) || SHARED_ERROR_CODES.has(code))) return failure(id, code, message);
275
+ if (code && (row.errorCodes.includes(code) || SHARED_ERROR_CODES.has(code)))
276
+ return failure(id, code, message, undefined, details);
269
277
  if (code === "resource_gone" || /not found|gone/i.test(message)) return failure(id, "resource_gone", message);
270
278
  if (code === "unknown_gate") return failure(id, "resource_gone", message);
271
- if (code === "invalid_input" || /invalid input/i.test(message)) return failure(id, "invalid_input", message);
272
- return failure(id, "internal", "Control operation failed.");
279
+ if (code === "invalid_input" || /invalid input/i.test(message))
280
+ return failure(id, "invalid_input", message, undefined, details);
281
+ return failure(id, "internal", "Control operation failed.", undefined, details);
273
282
  }
274
283
 
275
284
  async function execute(surface: ControlSurface, row: Operation, request: ControlRequest): Promise<ControlResponse> {
@@ -2065,6 +2065,24 @@
2065
2065
  "packages/coding-agent/test/sdk-operation-inventory.test.ts"
2066
2066
  ]
2067
2067
  },
2068
+ {
2069
+ "sourceId": "slash_command:handoff",
2070
+ "sourceFile": "packages/coding-agent/src/slash-commands/builtin-registry.ts",
2071
+ "sourceKind": "slash_command",
2072
+ "decision": "include",
2073
+ "sdkId": "session.handoff",
2074
+ "adapterMappings": {
2075
+ "telegram": "generic_safe",
2076
+ "discord": "generic_safe",
2077
+ "slack": "generic_safe",
2078
+ "mcp": "generic_safe",
2079
+ "acp": "generic_safe",
2080
+ "daemonCli": "generic_safe"
2081
+ },
2082
+ "testIds": [
2083
+ "packages/coding-agent/test/sdk-operation-inventory.test.ts"
2084
+ ]
2085
+ },
2068
2086
  {
2069
2087
  "sourceId": "slash_command:contribute-pr",
2070
2088
  "sourceFile": "packages/coding-agent/src/slash-commands/builtin-registry.ts",