@gajae-code/coding-agent 0.11.8 → 0.11.9

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 (57) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/dist/types/cli.d.ts +1 -0
  3. package/dist/types/config/model-profiles.d.ts +2 -3
  4. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
  5. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
  6. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
  7. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
  8. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
  9. package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
  10. package/dist/types/gjc-runtime/state-writer.d.ts +44 -0
  11. package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
  12. package/dist/types/modes/components/welcome.d.ts +2 -0
  13. package/dist/types/runtime-mcp/redaction.d.ts +2 -0
  14. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -1
  15. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -1
  16. package/dist/types/session/internal/managed-session-scope.d.ts +12 -2
  17. package/dist/types/session/internal/managed-session-storage.d.ts +8 -3
  18. package/dist/types/session/session-manager.d.ts +5 -1
  19. package/dist/types/session/session-storage.d.ts +13 -0
  20. package/dist/types/skill-state/active-state.d.ts +4 -0
  21. package/package.json +8 -8
  22. package/src/cli/mcp-cli.ts +4 -21
  23. package/src/cli.ts +32 -0
  24. package/src/commands/deep-interview.ts +9 -4
  25. package/src/commit/agentic/index.ts +3 -3
  26. package/src/commit/map-reduce/index.ts +2 -2
  27. package/src/config/model-profiles.ts +2 -3
  28. package/src/defaults/gjc/skills/deep-interview/SKILL.md +78 -163
  29. package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
  30. package/src/gjc-runtime/deep-interview-draft.ts +842 -0
  31. package/src/gjc-runtime/deep-interview-payload.ts +262 -0
  32. package/src/gjc-runtime/deep-interview-recorder.ts +294 -226
  33. package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
  34. package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
  35. package/src/gjc-runtime/deep-interview-state.ts +916 -4
  36. package/src/gjc-runtime/ralplan-runtime.ts +3 -1
  37. package/src/gjc-runtime/state-writer.ts +166 -2
  38. package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
  39. package/src/gjc-runtime/workflow-manifest.ts +191 -5
  40. package/src/internal-urls/docs-index.generated.ts +3 -2
  41. package/src/modes/components/welcome.ts +10 -2
  42. package/src/modes/interactive-mode.ts +29 -9
  43. package/src/prompts/system/system-prompt.md +1 -0
  44. package/src/runtime-mcp/json-rpc.ts +7 -4
  45. package/src/runtime-mcp/redaction.ts +30 -0
  46. package/src/sdk/broker/session-index.ts +11 -1
  47. package/src/sdk/bus/chat-daemon-control.ts +5 -3
  48. package/src/sdk/bus/telegram-daemon-contract.ts +3 -1
  49. package/src/sdk/bus/telegram-daemon.ts +18 -2
  50. package/src/session/agent-session.ts +86 -0
  51. package/src/session/internal/managed-session-scope.ts +206 -111
  52. package/src/session/internal/managed-session-storage.ts +17 -4
  53. package/src/session/session-manager.ts +96 -33
  54. package/src/session/session-storage.ts +23 -0
  55. package/src/skill-state/active-state.ts +10 -0
  56. package/src/task/executor.ts +13 -5
  57. package/src/tools/ask.ts +1 -0
@@ -70,6 +70,7 @@ import type { ToolExecutionHandle } from "./components/tool-execution";
70
70
  import { StatusLineComponent } from "./components/tool-status-header";
71
71
  import { composeToolText } from "./components/tool-transcript-format";
72
72
  import {
73
+ type RecentSession,
73
74
  WelcomeComponent,
74
75
  type WelcomeLogoMode,
75
76
  type LspServerInfo as WelcomeLspServerInfo,
@@ -631,15 +632,9 @@ export class InteractiveMode implements InteractiveModeContext {
631
632
  const modelName = this.session.model?.name ?? "Unknown";
632
633
  const providerName = this.session.model?.provider ?? "Unknown";
633
634
 
634
- // Get recent sessions
635
- const recentSessions = await logger.time("InteractiveMode.init:recentSessions", () =>
636
- getRecentSessions(this.sessionManager.getSessionDir()).then(sessions =>
637
- sessions.map(s => ({
638
- name: s.name,
639
- timeAgo: s.timeAgo,
640
- })),
641
- ),
642
- );
635
+ // Session history is display-only. Never hold the editor and keyboard behind
636
+ // transcript I/O; populate the welcome trail after the interactive surface exists.
637
+ const recentSessions: RecentSession[] = [];
643
638
 
644
639
  const startupQuiet = settings.get("startup.quiet");
645
640
  const welcomeLogoMode = resolveWelcomeLogoMode(settings.get("startup.welcomeBannerMode"));
@@ -763,6 +758,31 @@ export class InteractiveMode implements InteractiveModeContext {
763
758
  if (this.settings.get("tasksPane.defaultVisible")) this.showTasksPane();
764
759
  this.#syncIrcSidebarAvailabilityFromSettings();
765
760
  this.ui.requestRender(true);
761
+ if (this.#welcomeComponent) {
762
+ const welcomeComponent = this.#welcomeComponent;
763
+ const recentSessionsTimer = setTimeout(() => {
764
+ void logger
765
+ .time("InteractiveMode.init:recentSessions", () =>
766
+ getRecentSessions(this.sessionManager.getSessionDir()),
767
+ )
768
+ .then(sessions => {
769
+ if (this.#welcomeComponent !== welcomeComponent) return;
770
+ welcomeComponent.setRecentSessions(
771
+ sessions.map(session => ({
772
+ name: session.name,
773
+ timeAgo: session.timeAgo,
774
+ })),
775
+ );
776
+ this.ui.requestRender();
777
+ })
778
+ .catch(error => {
779
+ logger.debug("Failed to load recent sessions for welcome screen", {
780
+ error: error instanceof Error ? error.message : String(error),
781
+ });
782
+ });
783
+ }, 0);
784
+ recentSessionsTimer.unref?.();
785
+ }
766
786
 
767
787
  // GitHub star reminder (interactive-only). Register the decline-driven
768
788
  // injection contributor and schedule the launch nudge after the first
@@ -24,6 +24,7 @@ Optimize for correctness first, maintainability second, and brevity third. Prefe
24
24
  - Informational questions are answer-only/read-only unless the user explicitly requests a change, command, or execution.
25
25
  - Vague requirements use `/skill:deep-interview`: a requirements-only workflow that must not mutate product code. Its spec hands off as deep-interview → ralplan consensus → pending approval → separately approved execution.
26
26
  - Clear work with non-trivial architecture or sequencing risk uses `/skill:ralplan --deliberate` and stops pending approval.
27
+ - Deep-interview state is runtime-owned: diagnose with `gjc deep-interview sanity-check`, inspect only the affected bounded selector, and repair through typed operations using CLI-generated/edited drafts (`draft create|edit|show|check|rebase|discard`) consumed with `--draft-id`. Inline JSON request flags are compatibility-only; normal flow must never reconstruct a payload or full envelope.
27
28
  - Use `/skill:ultragoal` for durable goal ledgers and `/skill:team` for approved coordinated persistent work.
28
29
  - Delegate large implementation slices to `executor`; use `planner`, `architect`, or `critic` for bounded planning and review.
29
30
  - Active skills are authoritative: never ignore an invoked skill; read the full skill text and follow it exactly.
@@ -4,9 +4,9 @@
4
4
  * Lightweight utilities for calling MCP servers directly via HTTP
5
5
  * without maintaining persistent connections.
6
6
  */
7
- // biome-ignore assist/source/organizeImports: Keep independent MCP security imports on separate merge anchors.
8
- import { cancelMCPStream, MCP_HTTP_TIMEOUT_MS, MCP_MAX_CONTENT_BYTES, readMCPResponseText } from "./content-limits";
9
7
  import { logger } from "@gajae-code/utils";
8
+ import { cancelMCPStream, MCP_HTTP_TIMEOUT_MS, MCP_MAX_CONTENT_BYTES, readMCPResponseText } from "./content-limits";
9
+ import { redactMCPDiagnosticValue, redactMCPEndpoint } from "./redaction";
10
10
 
11
11
  /** Parse SSE response format (lines starting with "data: ") */
12
12
  export function parseSSE(text: string): unknown {
@@ -82,7 +82,7 @@ export async function callMCP<T = unknown>(
82
82
  if (!response.ok) {
83
83
  cancelMCPStream(response.body);
84
84
  const errorMsg = `MCP request failed: ${response.status} ${response.statusText}`;
85
- logger.error(errorMsg, { url, method, params });
85
+ logger.error(errorMsg, { url: redactMCPEndpoint(url), method, params: redactMCPDiagnosticValue(params) });
86
86
  throw new Error(errorMsg);
87
87
  }
88
88
 
@@ -90,7 +90,10 @@ export async function callMCP<T = unknown>(
90
90
  const result = parseSSE(text) as JsonRpcResponse<T> | null;
91
91
 
92
92
  if (!result) {
93
- logger.error("Failed to parse MCP response", { url, method, responseText: text.slice(0, 500) });
93
+ logger.error("Failed to parse MCP response", {
94
+ url: redactMCPEndpoint(url),
95
+ method,
96
+ });
94
97
  throw new Error("Failed to parse MCP response");
95
98
  }
96
99
 
@@ -0,0 +1,30 @@
1
+ const REDACTED = "<redacted>";
2
+ const SENSITIVE_KEY_PATTERN = /(?:token|secret|key|credential|password|authorization|auth|bearer|cookie|session)/i;
3
+
4
+ export function redactMCPEndpoint(value: string | undefined): string | undefined {
5
+ if (!value) return value;
6
+ try {
7
+ const url = new URL(value);
8
+ if (url.username) url.username = REDACTED;
9
+ if (url.password) url.password = REDACTED;
10
+ if (url.pathname !== "/") url.pathname = `/${REDACTED}`;
11
+ for (const key of Array.from(url.searchParams.keys())) {
12
+ url.searchParams.set(key, REDACTED);
13
+ }
14
+ url.hash = "";
15
+ return url.toString();
16
+ } catch {
17
+ return REDACTED;
18
+ }
19
+ }
20
+
21
+ export function redactMCPDiagnosticValue(value: unknown): unknown {
22
+ if (Array.isArray(value)) return value.map(redactMCPDiagnosticValue);
23
+ if (!value || typeof value !== "object") return value;
24
+ return Object.fromEntries(
25
+ Object.entries(value).map(([key, entry]) => [
26
+ key,
27
+ SENSITIVE_KEY_PATTERN.test(key) ? REDACTED : redactMCPDiagnosticValue(entry),
28
+ ]),
29
+ );
30
+ }
@@ -131,6 +131,7 @@ function compactEvents(events: SessionIndexEvent[]): SessionIndexEvent[] {
131
131
 
132
132
  async function appendSync(file: string, value: string): Promise<void> {
133
133
  const h = await fs.open(file, "a", 0o600);
134
+ let failure: { error: unknown } | undefined;
134
135
  try {
135
136
  const data = Buffer.from(`${value}\n`);
136
137
  for (let offset = 0; offset < data.length; ) {
@@ -139,9 +140,18 @@ async function appendSync(file: string, value: string): Promise<void> {
139
140
  offset += bytesWritten;
140
141
  }
141
142
  await h.sync();
142
- } finally {
143
+ } catch (error) {
144
+ failure = { error };
145
+ }
146
+ try {
143
147
  await h.close();
148
+ } catch (error) {
149
+ // Bun may report EBADF when concurrent child-pipe teardown has already
150
+ // released a fully written and fsynced append handle. The descriptor is
151
+ // closed in that case; every other close failure remains fatal.
152
+ if ((error as NodeJS.ErrnoException).code !== "EBADF" && failure === undefined) failure = { error };
144
153
  }
154
+ if (failure !== undefined) throw failure.error;
145
155
  }
146
156
 
147
157
  async function syncDirectory(file: string): Promise<void> {
@@ -32,11 +32,13 @@ export type ChatDaemonAction = "stop" | "reload";
32
32
  * authority declaration contract with bounded frame-delivery acknowledgement.
33
33
  * Generation 11 accepts typed retained exact-unlink cleanup authority — a
34
34
  * concrete detached quarantine plus a proven-absent canonical lock pathname —
35
- * when deleting an observed owner-lock lease.
35
+ * when deleting an observed owner-lock lease. Generation 12 refreshes retained
36
+ * native path and process authority semantics. Generation 13 rejects special
37
+ * files before retained native authority opens.
36
38
  */
37
39
  export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
38
- discord: 11,
39
- slack: 11,
40
+ discord: 13,
41
+ slack: 13,
40
42
  };
41
43
 
42
44
  export function chatDaemonGeneration(kind: ChatDaemonKind): number {
@@ -38,5 +38,7 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
38
38
  * Generation 25 adds startup dead-root prune + leak-artifact self-heal
39
39
  * on TelegramNotificationDaemon.run (#2958). Generation 26 adds bounded reload
40
40
  * cooldown and lazy Telegram topic lifecycle safeguards (#2956, #2960, #2984).
41
+ * Generation 27 refreshes retained native path and process authority semantics.
42
+ * Generation 28 rejects special files before retained native authority opens.
41
43
  */
42
- export const DAEMON_GENERATION = 26;
44
+ export const DAEMON_GENERATION = 28;
@@ -3668,6 +3668,12 @@ export class TelegramNotificationDaemon {
3668
3668
  private readonly botApi: BotApi;
3669
3669
  private readonly effects = new TelegramEffectSupervisor();
3670
3670
  private readonly topics = new TopicRegistry();
3671
+ /**
3672
+ * Telegram may accept createForumTopic while returning an unusable success
3673
+ * payload. Remember that ambiguity per endpoint so later frames cannot repeat
3674
+ * the external side effect. Explicit API failures remain retryable.
3675
+ */
3676
+ readonly #malformedTopicCreateEndpoints = new Map<string, string>();
3671
3677
  /** Serializes registry snapshots so an older atomic write cannot overwrite newer rename state. */
3672
3678
  /** Legacy sockets have no durable token, so explicit teardown is their revocation fence. */
3673
3679
  private readonly droppedSessions = new WeakSet<SessionSocket>();
@@ -5844,6 +5850,9 @@ export class TelegramNotificationDaemon {
5844
5850
  if (session && sessionId === session.sessionId && this.#logicalSessionId(session) !== sessionId) return undefined;
5845
5851
  const capturedCreationLease = creationLease ?? (session ? this.#socketLease(session, sessionId) : undefined);
5846
5852
  if (session?.logicalSessionIdTrusted && !capturedCreationLease) return undefined;
5853
+ const creationEndpointKey = session?.endpointDigest ?? session?.endpointKey ?? "unbound";
5854
+ if (this.#malformedTopicCreateEndpoints.get(sessionId) === creationEndpointKey)
5855
+ throw new Error("createForumTopic: invalid message_thread_id");
5847
5856
  const existing = this.topics.get(sessionId);
5848
5857
  if (existing?.authorityState === "delete_pending" || existing?.bindingMalformed) return undefined;
5849
5858
  if (existing) return existing.topicId;
@@ -5867,10 +5876,17 @@ export class TelegramNotificationDaemon {
5867
5876
  throw new Error("topic authority was revoked during creation");
5868
5877
  const res = await this.botApi.call("createForumTopic", { chat_id: this.opts.chatId, name });
5869
5878
  if (isThreadedModeCapabilityRefusal(res)) throw new ThreadedModeCapabilityRefusal();
5870
- const tid = (res as { result?: { message_thread_id?: unknown } }).result?.message_thread_id;
5871
- if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0)
5879
+ const response = res as {
5880
+ ok?: unknown;
5881
+ result?: { message_thread_id?: unknown };
5882
+ };
5883
+ const tid = response.result?.message_thread_id;
5884
+ if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0) {
5885
+ if (response.ok === true) this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey);
5872
5886
  throw new Error("createForumTopic: invalid message_thread_id");
5887
+ }
5873
5888
  acceptedTopicId = String(tid);
5889
+ this.#malformedTopicCreateEndpoints.delete(sessionId);
5874
5890
  if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) {
5875
5891
  if (
5876
5892
  !this.topics.fenceAcceptedCreateForLease(
@@ -785,6 +785,22 @@ type RetryErrorClassification =
785
785
 
786
786
  const BARE_DEFAULT_WATCHDOG_ERROR =
787
787
  /^(?:[A-Za-z][A-Za-z0-9-]*(?: [A-Za-z][A-Za-z0-9-]*){0,3} )stream (?:timed out while waiting for the first event|stalled while waiting for the next event)$/;
788
+ const KIMI_CODE_FIRST_EVENT_TIMEOUT_MESSAGES = {
789
+ "anthropic-messages": new Set([
790
+ "Provider stream timed out while waiting for the first event",
791
+ "Anthropic stream timed out while waiting for the first event",
792
+ ]),
793
+ "openai-completions": new Set([
794
+ "Provider stream timed out while waiting for the first event",
795
+ "OpenAI completions stream timed out while waiting for the first event",
796
+ ]),
797
+ } as const;
798
+
799
+ const ALIBABA_TOKEN_PLAN_PROVIDER = "alibaba-token-plan";
800
+ const ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MESSAGES = {
801
+ "openai-responses": "OpenAI responses stream timed out while waiting for the first event",
802
+ "openai-completions": "OpenAI completions stream timed out while waiting for the first event",
803
+ } as const;
788
804
 
789
805
  function hasBareDefaultRetryDisqualifyingFacts(message: AssistantMessage): boolean {
790
806
  if (message.errorKind !== undefined || message.errorStatus !== undefined) return true;
@@ -12432,6 +12448,7 @@ export class AgentSession {
12432
12448
  }
12433
12449
  const retryAfterMs = this.#parseRetryAfterMsFromError(message);
12434
12450
  const shouldRetry =
12451
+ !this.#isTerminalProviderCompactionTimeout(candidate, message) &&
12435
12452
  retrySettings.enabled &&
12436
12453
  attempt < retrySettings.maxRetries &&
12437
12454
  (retryAfterMs !== undefined ||
@@ -12617,7 +12634,61 @@ export class AgentSession {
12617
12634
  * unknown/no-code errors are retryable.
12618
12635
  */
12619
12636
 
12637
+ #isKimiCodeFirstEventTimeout(message: AssistantMessage): boolean {
12638
+ if (message.stopReason !== "error" || message.provider !== "kimi-code") return false;
12639
+ const errorMessage = message.errorMessage ?? "";
12640
+ if (message.api === "anthropic-messages") {
12641
+ return KIMI_CODE_FIRST_EVENT_TIMEOUT_MESSAGES["anthropic-messages"].has(errorMessage);
12642
+ }
12643
+ if (message.api === "openai-completions") {
12644
+ return KIMI_CODE_FIRST_EVENT_TIMEOUT_MESSAGES["openai-completions"].has(errorMessage);
12645
+ }
12646
+ return false;
12647
+ }
12648
+
12649
+ #isKimiCodeCompactionTimeout(candidate: Model, errorMessage: string): boolean {
12650
+ if (candidate.provider !== "kimi-code") return false;
12651
+ return /^(?:Summarization failed|Turn prefix summarization failed): (?:Provider|Anthropic|OpenAI completions) stream timed out while waiting for the first event$/.test(
12652
+ errorMessage,
12653
+ );
12654
+ }
12655
+
12656
+ #alibabaTokenPlanCanonicalTimeoutForApi(api: string): string | undefined {
12657
+ if (api === "openai-responses" || api === "openai-completions") {
12658
+ return ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MESSAGES[api];
12659
+ }
12660
+ return undefined;
12661
+ }
12662
+
12663
+ #isAlibabaTokenPlanFirstEventTimeout(message: AssistantMessage): boolean {
12664
+ if (message.stopReason !== "error" || message.provider !== ALIBABA_TOKEN_PLAN_PROVIDER) return false;
12665
+ const canonicalMessage = this.#alibabaTokenPlanCanonicalTimeoutForApi(message.api);
12666
+ return canonicalMessage !== undefined && message.errorMessage === canonicalMessage;
12667
+ }
12668
+
12669
+ #isAlibabaTokenPlanCompactionTimeout(candidate: Model, errorMessage: string): boolean {
12670
+ if (candidate.provider !== ALIBABA_TOKEN_PLAN_PROVIDER) return false;
12671
+ const canonicalMessage = this.#alibabaTokenPlanCanonicalTimeoutForApi(candidate.api);
12672
+ if (canonicalMessage === undefined) return false;
12673
+ return (
12674
+ errorMessage === `Summarization failed: ${canonicalMessage}` ||
12675
+ errorMessage === `Turn prefix summarization failed: ${canonicalMessage}`
12676
+ );
12677
+ }
12678
+
12679
+ #isTerminalProviderFirstEventTimeout(message: AssistantMessage): boolean {
12680
+ return this.#isKimiCodeFirstEventTimeout(message) || this.#isAlibabaTokenPlanFirstEventTimeout(message);
12681
+ }
12682
+
12683
+ #isTerminalProviderCompactionTimeout(candidate: Model, errorMessage: string): boolean {
12684
+ return (
12685
+ this.#isKimiCodeCompactionTimeout(candidate, errorMessage) ||
12686
+ this.#isAlibabaTokenPlanCompactionTimeout(candidate, errorMessage)
12687
+ );
12688
+ }
12689
+
12620
12690
  #isRetryableError(message: AssistantMessage): boolean {
12691
+ if (this.#isTerminalProviderFirstEventTimeout(message)) return false;
12621
12692
  if (message.errorMessage?.startsWith("Model fallback chain exhausted;")) return false;
12622
12693
  const transportFailure = message.transportFailure;
12623
12694
  const contextWindow = this.model?.contextWindow ?? 0;
@@ -12767,6 +12838,9 @@ export class AgentSession {
12767
12838
  if (isTerminalHttp4xx && (explicitStatus !== undefined || !/rate.?limit|too many requests/i.test(err))) {
12768
12839
  return "terminal";
12769
12840
  }
12841
+ if (this.#isTerminalProviderFirstEventTimeout(message)) {
12842
+ return "terminal";
12843
+ }
12770
12844
  // A first-event timeout on ollama-cloud (the ollama-chat API) must not
12771
12845
  // join the unbounded transient class: each continuation retry re-issues
12772
12846
  // the full request to a remote, billable backend, so an unbounded loop
@@ -12949,6 +13023,18 @@ export class AgentSession {
12949
13023
  },
12950
13024
  };
12951
13025
  }
13026
+ if (this.#isTerminalProviderFirstEventTimeout(outcome.failure.message)) {
13027
+ // The managed transport discarded this attempt before session policy saw it.
13028
+ // Remove its provisional controller charge and surface the original message.
13029
+ this.#defaultFallbackChain().discardStartedAttempt();
13030
+ return {
13031
+ type: "terminal",
13032
+ terminal: {
13033
+ stopReason: "error",
13034
+ messages: [outcome.failure.message],
13035
+ },
13036
+ };
13037
+ }
12952
13038
  return this.#handleRetryableError(
12953
13039
  outcome.failure.message,
12954
13040
  true,