@adhdev/daemon-core 1.0.18-rc.2 → 1.0.18-rc.20

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 (82) hide show
  1. package/dist/cli-adapters/cli-state-engine.d.ts +77 -0
  2. package/dist/cli-adapters/provider-cli-shared.d.ts +33 -0
  3. package/dist/cli-adapters/pty-transport.d.ts +2 -1
  4. package/dist/commands/cli-manager.d.ts +9 -0
  5. package/dist/commands/process-lifecycle.d.ts +43 -0
  6. package/dist/commands/upgrade-helper.d.ts +1 -0
  7. package/dist/commands/windows-atomic-upgrade.d.ts +17 -1
  8. package/dist/config/mesh-json-config.d.ts +62 -0
  9. package/dist/index.d.ts +4 -2
  10. package/dist/index.js +6674 -4237
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +6361 -3927
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/mesh/coordinator-prompt.d.ts +76 -0
  15. package/dist/mesh/mesh-active-work.d.ts +1 -1
  16. package/dist/mesh/mesh-disk-retention.d.ts +105 -0
  17. package/dist/mesh/mesh-ledger.d.ts +28 -1
  18. package/dist/mesh/mesh-node-identity.d.ts +23 -0
  19. package/dist/mesh/mesh-refine-gates.d.ts +89 -0
  20. package/dist/mesh/mesh-remote-event-pull.d.ts +3 -0
  21. package/dist/mesh/mesh-runtime-store.d.ts +11 -0
  22. package/dist/mesh/mesh-work-queue.d.ts +24 -1
  23. package/dist/providers/auto-approve-modes.d.ts +14 -0
  24. package/dist/providers/chat-message-normalization.d.ts +22 -0
  25. package/dist/providers/cli-provider-instance-types.d.ts +4 -0
  26. package/dist/providers/cli-provider-instance.d.ts +78 -0
  27. package/dist/providers/contracts.d.ts +17 -0
  28. package/dist/providers/provider-schema.d.ts +1 -0
  29. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
  30. package/dist/providers/types/interactive-prompt.d.ts +18 -0
  31. package/dist/repo-mesh-types.d.ts +38 -6
  32. package/dist/shared-types.d.ts +4 -2
  33. package/package.json +3 -3
  34. package/src/boot/daemon-lifecycle.ts +10 -0
  35. package/src/cli-adapters/cli-state-engine.ts +220 -3
  36. package/src/cli-adapters/provider-cli-adapter.ts +41 -11
  37. package/src/cli-adapters/provider-cli-shared.ts +48 -0
  38. package/src/cli-adapters/pty-transport.d.ts +2 -1
  39. package/src/cli-adapters/pty-transport.ts +1 -1
  40. package/src/cli-adapters/session-host-transport.ts +8 -3
  41. package/src/commands/cli-manager.ts +72 -8
  42. package/src/commands/high-family/mesh-coordinator-launch.ts +17 -2
  43. package/src/commands/high-family/mesh-events.ts +13 -2
  44. package/src/commands/med-family/mesh-crud.ts +155 -0
  45. package/src/commands/med-family/mesh-queue.ts +74 -1
  46. package/src/commands/process-lifecycle.ts +248 -0
  47. package/src/commands/router-refine.ts +71 -4
  48. package/src/commands/router.ts +8 -0
  49. package/src/commands/upgrade-helper.ts +106 -82
  50. package/src/commands/windows-atomic-upgrade.ts +281 -35
  51. package/src/config/mesh-json-config.ts +111 -0
  52. package/src/index.ts +21 -1
  53. package/src/mesh/coordinator-prompt.ts +258 -11
  54. package/src/mesh/mesh-active-work.ts +11 -1
  55. package/src/mesh/mesh-completion-synthesis.ts +12 -1
  56. package/src/mesh/mesh-disk-retention.ts +370 -0
  57. package/src/mesh/mesh-event-classify.ts +19 -0
  58. package/src/mesh/mesh-event-forwarding.ts +64 -6
  59. package/src/mesh/mesh-events-utils.ts +42 -0
  60. package/src/mesh/mesh-ledger.ts +77 -0
  61. package/src/mesh/mesh-node-identity.ts +49 -0
  62. package/src/mesh/mesh-queue-assignment.ts +210 -11
  63. package/src/mesh/mesh-reconcile-loop.ts +306 -3
  64. package/src/mesh/mesh-refine-gates.ts +300 -0
  65. package/src/mesh/mesh-remote-event-pull.ts +68 -47
  66. package/src/mesh/mesh-runtime-store.ts +21 -0
  67. package/src/mesh/mesh-work-queue.ts +85 -2
  68. package/src/providers/auto-approve-modes.ts +103 -0
  69. package/src/providers/chat-message-normalization.ts +53 -0
  70. package/src/providers/cli-provider-instance-types.ts +53 -0
  71. package/src/providers/cli-provider-instance.ts +497 -15
  72. package/src/providers/contracts.ts +25 -1
  73. package/src/providers/provider-schema.ts +83 -0
  74. package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
  75. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
  76. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +44 -0
  77. package/src/providers/types/interactive-prompt.ts +77 -0
  78. package/src/repo-mesh-types.ts +112 -12
  79. package/src/session-host/managed-host.ts +34 -0
  80. package/src/shared-types.ts +9 -1
  81. package/src/status/reporter.ts +1 -0
  82. package/src/status/snapshot.ts +2 -0
@@ -389,6 +389,21 @@ export interface ProviderCompatibilityEntry {
389
389
  ideVersion: string;
390
390
  scriptDir: string;
391
391
  }
392
+ export type AutoApproveModeStrategy = 'pty-parse-default' | 'launch-args' | 'post-boot-command';
393
+ export type AutoApproveModeRisk = 'safe' | 'caution' | 'dangerous';
394
+ export interface AutoApproveMode {
395
+ id: string;
396
+ label: string;
397
+ strategy: AutoApproveModeStrategy;
398
+ risk: AutoApproveModeRisk;
399
+ warning?: string;
400
+ launchArgs?: string[];
401
+ removeArgs?: string[];
402
+ }
403
+ export interface AutoApproveModesConfig {
404
+ default: string;
405
+ modes: AutoApproveMode[];
406
+ }
392
407
  export interface ProviderModule {
393
408
  /** Unique identifier (e.g. 'cline', 'cursor', 'gemini-cli') */
394
409
  type: string;
@@ -418,6 +433,8 @@ export interface ProviderModule {
418
433
  status?: string;
419
434
  /** Inventory/support detail string maintained in adhdev-providers */
420
435
  details?: string;
436
+ /** Provider-specific auto-approve choices and their launch/runtime strategy. */
437
+ autoApproveModes?: AutoApproveModesConfig;
421
438
  /** Install instructions (shown when command is missing) */
422
439
  install?: string;
423
440
  /** Custom version detection command (e.g. 'cursor --version', 'claude -v') */
@@ -3,3 +3,4 @@ export interface ProviderValidationResult {
3
3
  warnings: string[];
4
4
  }
5
5
  export declare function validateProviderDefinition(raw: unknown): ProviderValidationResult;
6
+ export declare function validateAutoApproveModes(raw: unknown, errors: string[]): void;
@@ -75,6 +75,20 @@ export interface DetectStatusTuiSpec {
75
75
  declare function scopeText(input: CliStatusInput, scope: SpinnerSpec['scope'] | SettledPromptSpec['scope'], windowLines: number | undefined): string;
76
76
  declare function spinnerMatches(spec: SpinnerSpec, input: CliStatusInput): boolean;
77
77
  declare function settledPromptMatches(spec: SettledPromptSpec, input: CliStatusInput): boolean;
78
+ /**
79
+ * APPROVAL-PICKER-MISROUTE (mission f1d25e11) defense-in-depth: an
80
+ * AskUserQuestion multi-choice picker is NOT an approval modal. Its option rows
81
+ * ("❯ 1. label") can otherwise satisfy the approval button cue and get
82
+ * mis-classified as `waiting_approval`, so the worker's question is surfaced to
83
+ * the coordinator as a task_approval_needed (→ mesh_approve, which cannot answer
84
+ * it). The picker carries a distinctive signature the approval FSM never does:
85
+ * the claude TUI select footer ("Enter to select … Esc to cancel") together with
86
+ * the freeform escape hatch ("Type something" / "Chat about this"). When both are
87
+ * present the screen is a question picker — surfaced separately as
88
+ * waiting_choice — so the approval matchers must yield. Mirrors the legacy
89
+ * looksLikeSelectionPicker guard (cli-provider-instance.ts) ported to SDK-v1.
90
+ */
91
+ export declare function isAskUserQuestionPickerSignature(text: string): boolean;
78
92
  declare function modalMatches(spec: ModalSpec, input: CliStatusInput): boolean;
79
93
  export declare function buildDetectStatusFromTui(spec: DetectStatusTuiSpec): CliDetectStatusFn;
80
94
  export declare const __internal: {
@@ -28,6 +28,24 @@ export interface InteractiveAnswer {
28
28
  }
29
29
  export declare function normalizeInteractivePrompt(raw: unknown): InteractivePrompt | null;
30
30
  export declare function normalizeInteractivePromptResponse(raw: unknown): InteractivePromptResponse;
31
+ /**
32
+ * Resolve a coordinator-friendly answer form into the strict, questionId-keyed
33
+ * InteractivePromptResponse the TUI/answer machinery consumes (mission f1d25e11).
34
+ *
35
+ * mesh_answer_question lets the coordinator answer against the option LABELS or
36
+ * 1-based INDEXES it saw in the agent:waiting_choice event, without having to
37
+ * reconstruct the exact questionId → selectedLabels map. This resolves that
38
+ * ergonomic form against the AUTHORITATIVE active prompt (the daemon holds it),
39
+ * so index/label resolution and question ordering are correct by construction.
40
+ *
41
+ * Accepted `raw` shapes:
42
+ * - The strict form ({ promptId, answers: { [questionId]: { selectedLabels } } })
43
+ * — passed straight to normalizeInteractivePromptResponse (back-compat).
44
+ * - The friendly form ({ promptId, answers: [ { questionId?, select?, freeform? } ] })
45
+ * — entries map to questions by questionId, else by array position. `select`
46
+ * is a label (string) / 1-based index (number) / array of either.
47
+ */
48
+ export declare function resolveInteractivePromptResponse(prompt: InteractivePrompt, raw: unknown): InteractivePromptResponse;
31
49
  export declare function buildClaudeInteractiveToolResult(response: InteractivePromptResponse): string;
32
50
  export interface ClaudeInteractiveTuiPage {
33
51
  screenText: string;
@@ -14,6 +14,8 @@ import type { GitRepoStatus, GitCompactSummary } from './git/git-types.js';
14
14
  import type { MeshMissionSummary, MeshMissionSlimSummary } from './mesh/mesh-missions.js';
15
15
  import type { MeshMagiActivitySummary } from './mesh/mesh-magi-status.js';
16
16
  import type { MagiKindPanelMap, DifficultyBrainMap, NodeCapabilitySlot } from '@adhdev/mesh-shared';
17
+ import type { ProviderModule } from './providers/contracts.js';
18
+ import type { RepoMeshDeclarativeConfig } from './config/mesh-json-config.js';
17
19
  export interface RepoMesh {
18
20
  id: string;
19
21
  name: string;
@@ -255,6 +257,8 @@ export interface RepoMeshPolicy {
255
257
  * A node policy may override this per-node (RepoMeshNodePolicy.delegatedWorkerAutoApprove).
256
258
  */
257
259
  delegatedWorkerAutoApprove?: boolean;
260
+ /** Explicit opt-in required before delegated workers may use a dangerous provider mode. */
261
+ delegatedWorkerDangerousModeAllow?: boolean;
258
262
  /**
259
263
  * MESH-SEND-KEYS (feature 3): opt-in to allow the coordinator to inject
260
264
  * DESTRUCTIVE keys (CTRL_C / ESC) into a worker PTY via mesh_send_keys. These
@@ -406,6 +410,8 @@ export interface RepoMeshNodePolicy {
406
410
  * precedence over the mesh-level policy for worker sessions launched onto this node.
407
411
  */
408
412
  delegatedWorkerAutoApprove?: boolean;
413
+ /** Per-node override for dangerous delegated worker mode authorization. */
414
+ delegatedWorkerDangerousModeAllow?: boolean;
409
415
  /**
410
416
  * MESH-SEND-KEYS (feature 3): per-node override for
411
417
  * RepoMeshPolicy.allowSendKeysDestructive.
@@ -524,13 +530,39 @@ export declare function normalizeAutoFastForwardPolicy(value: unknown): NonNulla
524
530
  */
525
531
  export declare function mergeAndNormalizePolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMeshPolicy> | undefined): RepoMeshPolicy;
526
532
  /**
527
- * Resolve whether a delegated worker session launched onto `nodePolicy` (within a mesh
528
- * governed by `meshPolicy`) should auto-approve. Precedence: node override mesh policy
529
- * default true. The result is stamped into the worker launch settings envelope as
530
- * `autoApprove`; it wins over the global per-provider-type autoApprove config because the
531
- * launch path merges the envelope as a settingsOverride on top of the provider defaults.
533
+ * Resolve delegated worker auto-approve. Legacy providers return a boolean. Providers
534
+ * with modes return a mode id, except a dangerous mode is downgraded to a
535
+ * non-dangerous PTY mode unless mesh/node policy explicitly opts in.
536
+ *
537
+ * THREE EXPLICIT STAGES do not collapse them; the ordering is a hard MAGI
538
+ * invariant:
539
+ *
540
+ * ① ENABLE gate (machine-local policy only): node boolean > mesh boolean.
541
+ * `enabled=false` returns `false` IMMEDIATELY, BEFORE any mode selection.
542
+ * The repo `mesh.json` providerDefaults has ZERO influence here — a
543
+ * node/mesh opt-out is never overridden by a repo-declared requested mode.
544
+ *
545
+ * ② MODE selection (only when enabled === true):
546
+ * task override [FUTURE — param reserved below, not yet wired] >
547
+ * repo mesh.json providerDefaults.autoApproveModes[providerType] >
548
+ * provider spec autoApproveModes.default.
549
+ * A repo-requested mode ID is adopted ONLY when it exists in the provider's
550
+ * own `autoApproveModes.modes`; an unknown/stale/typo'd ID is IGNORED and we
551
+ * fall back to the provider default (fail-closed: never coerce into a
552
+ * dangerous mode via a bad ID).
553
+ *
554
+ * ③ DANGEROUS gate: whichever mode stage ② picked, if it is dangerous and the
555
+ * machine-local delegatedWorkerDangerousModeAllow is not set, downgrade to a
556
+ * non-dangerous PTY-parse mode (or `false` if none exists).
532
557
  */
533
- export declare function resolveDelegatedWorkerAutoApprove(meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove'> | null, nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove'> | null): boolean;
558
+ export declare function resolveDelegatedWorkerAutoApprove(meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null, nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null, provider?: Pick<ProviderModule, 'autoApproveModes'> | null, repoConfig?: RepoMeshDeclarativeConfig | null, providerType?: string | null): boolean | string;
559
+ export declare function resolveDelegatedWorkerDangerousModeAllow(meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerDangerousModeAllow'> | null, nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerDangerousModeAllow'> | null): boolean;
560
+ /** Shape a boolean-or-mode resolution for the settings precedence contract. */
561
+ export declare function delegatedWorkerAutoApproveSettings(meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null, nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove' | 'delegatedWorkerDangerousModeAllow'> | null, provider?: Pick<ProviderModule, 'autoApproveModes'> | null, repoConfig?: RepoMeshDeclarativeConfig | null, providerType?: string | null): {
562
+ autoApprove: boolean | undefined;
563
+ autoApproveMode: string | undefined;
564
+ delegatedWorkerDangerousModeAllow: boolean;
565
+ };
534
566
  /**
535
567
  * MESH-SEND-KEYS (feature 3): resolve whether DESTRUCTIVE key injection
536
568
  * (CTRL_C/ESC via mesh_send_keys) is permitted for a node. Node policy overrides
@@ -13,7 +13,7 @@ export type { ProviderState, ProviderStatus, ActiveChatData, IdeProviderState, C
13
13
  export type { ProviderErrorReason } from './providers/provider-instance.js';
14
14
  import type { ActiveChatData as _ActiveChatData, ProviderErrorReason as _ProviderErrorReason } from './providers/provider-instance.js';
15
15
  import type { WorkspaceEntry } from './config/workspaces.js';
16
- import type { ProviderMeshCoordinatorConfig, ProviderResumeCapability } from './providers/contracts.js';
16
+ import type { AutoApproveModesConfig, ProviderMeshCoordinatorConfig, ProviderResumeCapability } from './providers/contracts.js';
17
17
  import type { GitCompactSummary, GitWorkspaceUpdate, WorkspaceGitSubscriptionParams } from './git/git-types.js';
18
18
  import type { InteractivePrompt } from './providers/types/interactive-prompt.js';
19
19
  export type { GitCommandName, GitCompactSummary, GitDiffSummary, GitFailureReason, GitFileChange, GitFileChangeStatus, GitRepoIdentity, GitRepoStatus, GitSnapshot, GitSnapshotCompareSummary, GitSnapshotReason, GitWorkspaceUpdate, WorkspaceGitSubscriptionParams, } from './git/git-types.js';
@@ -428,6 +428,8 @@ export interface AvailableProviderInfo {
428
428
  lastVerification?: MachineProviderCheckResult;
429
429
  /** Provider-declared Repo Mesh coordinator/MCP behavior. */
430
430
  meshCoordinator?: ProviderMeshCoordinatorConfig;
431
+ /** Provider-declared auto-approve choices shown by session launch UIs. */
432
+ autoApproveModes?: AutoApproveModesConfig;
431
433
  /** BRAIN-ROUTING: suggested model values for the new-session model dropdown. */
432
434
  modelOptions?: string[];
433
435
  /** BRAIN-ROUTING: reasoning-effort values for the new-session thinking dropdown. */
@@ -624,7 +626,7 @@ export interface DashboardBootstrapDaemonEntry extends Partial<CloudDaemonSummar
624
626
  p2p?: StatusReportPayload['p2p'];
625
627
  timestamp?: number;
626
628
  }
627
- export type DaemonStatusEventName = 'agent:generating_started' | 'agent:waiting_approval' | 'agent:generating_completed' | 'agent:stopped' | 'monitor:no_progress' | 'monitor:long_generating';
629
+ export type DaemonStatusEventName = 'agent:generating_started' | 'agent:waiting_approval' | 'agent:waiting_choice' | 'agent:generating_completed' | 'agent:stopped' | 'monitor:no_progress' | 'monitor:long_generating';
628
630
  /** Minimal daemon-originated event payload relayed through the server. */
629
631
  export interface DaemonStatusEventPayload {
630
632
  event: DaemonStatusEventName;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.18-rc.2",
3
+ "version": "1.0.18-rc.20",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "1.0.18-rc.2",
51
- "@adhdev/session-host-core": "1.0.18-rc.2",
50
+ "@adhdev/mesh-shared": "1.0.18-rc.20",
51
+ "@adhdev/session-host-core": "1.0.18-rc.20",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -39,6 +39,7 @@ import type { IdeProviderInstance } from '../providers/ide-provider-instance.js'
39
39
  import { createDefaultGitCommandServices } from '../git/git-commands.js';
40
40
  import { setupMeshEventForwarding } from '../mesh/mesh-events.js';
41
41
  import { setupMeshReconcileLoop } from '../mesh/mesh-reconcile-loop.js';
42
+ import { MeshRuntimeStore } from '../mesh/mesh-runtime-store.js';
42
43
  import { loadMeshCoordinatorRegistry } from '../mesh/coordinator-registry.js';
43
44
  import { applyProcessHardening } from './process-hardening.js';
44
45
  import { installProviderProcessShim } from '../providers/sdk/v1/sandbox/require-whitelist.js';
@@ -503,4 +504,13 @@ export async function shutdownDaemonComponents(components: DaemonComponents): Pr
503
504
  try { m.disconnect(); } catch { /* noop */ }
504
505
  }
505
506
  cdpManagers.clear();
507
+
508
+ // 7. VACUUM the mesh runtime DB. Retention prunes rows with DELETE (frees pages
509
+ // inside the file but never shrinks it); the mesh-runtime.db grew to hundreds of
510
+ // MB (mission 86def38d disk-accumulation bootstrap failure) because it was never
511
+ // compacted. Do this LAST, after all writers (reconcile loop, CLIs, instances)
512
+ // are stopped, so nothing contends for the exclusive VACUUM lock. Best-effort —
513
+ // vacuum() swallows its own errors; the try/catch guards the getInstance() throw
514
+ // when the store never opened (degraded JSONL-only mode).
515
+ try { MeshRuntimeStore.getInstance().vacuum(); } catch { /* store unavailable — nothing to vacuum */ }
506
516
  }
@@ -15,6 +15,7 @@ import {
15
15
  import {
16
16
  buildCliScreenSnapshot,
17
17
  compactPromptText,
18
+ isPurePtyTranscriptProvider,
18
19
  normalizePromptText,
19
20
  promptLikelyVisible,
20
21
  type CliChatMessage,
@@ -86,6 +87,18 @@ interface IdleFinishCandidate {
86
87
  assistantLength: number;
87
88
  }
88
89
 
90
+ /**
91
+ * Minimal shape computeApprovalContentSignature / isStaleResolvedApproval
92
+ * actually need — deliberately narrower than the full CliBufferSnapshot so
93
+ * callers outside the settled-eval loop (e.g. the adapter's startup-gate
94
+ * modal parse in getStatus/getDebugState) can supply just these two fields
95
+ * without having to fabricate an entire snapshot.
96
+ */
97
+ interface ApprovalSignatureSnapshot {
98
+ screenText?: string;
99
+ accumulatedBuffer?: string;
100
+ }
101
+
89
102
  interface SettledEvalContext {
90
103
  now: number;
91
104
  modal: { message: string; buttons: string[] } | null;
@@ -155,6 +168,22 @@ export class CliStateEngine {
155
168
  // ── Approval ─────────────────────────────────────
156
169
  lastApprovalResolvedAt = 0;
157
170
  lastResolvedModalMessage = '';
171
+ /**
172
+ * Normalized approval-context signature captured at resolve time (see
173
+ * `computeApprovalContentSignature`) — the screen text with blank-line
174
+ * padding and any manifest-declared chrome (transcriptPty.chromePatterns,
175
+ * spinner.patterns) stripped out. Used by applyWaitingApproval's
176
+ * isStaleResolvedRepaint check to tell "the same already-answered modal,
177
+ * re-parsed from a screen that has not meaningfully changed" apart from
178
+ * "a genuinely new approval" — content-based, not time-based, because
179
+ * ordinary TUI chrome (status bar, context meter, blank-line repaint)
180
+ * keeps producing fresh PTY bytes on an otherwise-unchanged screen and
181
+ * defeats any output-timestamp discriminator within a few hundred ms.
182
+ * Cleared whenever a turn starts or the session tears down (see
183
+ * onTurnStarted / resetActiveTurnState / onPtyExit) so a next-turn
184
+ * approval is never compared against stale prior-turn state.
185
+ */
186
+ lastApprovalResolvedContentSignature = '';
158
187
  /**
159
188
  * Monotonic counter bumped every time the FSM *enters* waiting_approval
160
189
  * with a freshly captured modal (see `applyWaitingApproval`). It is the
@@ -304,12 +333,61 @@ export class CliStateEngine {
304
333
  // anchor its window on dispatch time rather than completion time.
305
334
  this.currentTurnStartedAt = Date.now();
306
335
  this.responseEpoch += 1;
336
+ // A new turn's own prompt echo is real new content, so the stale-repaint
337
+ // guard's signature would naturally drift anyway — but clear the
338
+ // resolve-time bookkeeping explicitly here too, so a next-turn approval
339
+ // that happens to share message text with a previous turn's already-
340
+ // resolved one is never compared against stale prior-turn state.
341
+ this.clearApprovalResolutionMemory();
342
+ // (fix: kimi K3 send→idle-looking→generating lag / missed-generating on
343
+ // fast tool turns) For transcriptAuthority:'provider' providers (kimi,
344
+ // and other native-transcript sources), the PTY-scanned parser always
345
+ // returns messages:[] — the provider owns the transcript, not the PTY —
346
+ // so evaluateSettled's shouldHoldGenerating fast-path exception
347
+ // (hasFinalCurrentTurnAssistant) can never release early and
348
+ // recent_activity_hold ends up carrying the ENTIRE "is this turn still
349
+ // generating" signal for these providers regardless of what the spinner
350
+ // script does. That fallback only fires once a settle tick actually
351
+ // runs, which needs PTY output to schedule — for a model that "thinks"
352
+ // silently for many seconds before its first repaint (observed 12-20s
353
+ // for Kimi K3's default "high" effort), the dashboard looks idle for
354
+ // that whole window even though the turn was already accepted. Worse,
355
+ // a turn that fully completes (tool calls + reply) within a single
356
+ // settle debounce window can go straight idle→idle with no visible
357
+ // generating state at all (observed live with a yolo tool-use turn).
358
+ // onTurnStarted is the authoritative "a turn was just submitted" signal
359
+ // — promote to generating immediately instead of waiting on PTY-driven
360
+ // detection. This does not weaken completion detection: applyIdle /
361
+ // finishResponse (authoritative settled evidence) still own the actual
362
+ // idle transition, unchanged. Scoped to transcriptAuthority:'provider'
363
+ // only, so PTY-authoritative providers (whose spinner/settled parsing
364
+ // already drives generating promptly) are unaffected.
365
+ //
366
+ // (fix: kimi pure-PTY completion-emit) The pure-PTY full-buffer class
367
+ // (kimi and kin — see isPurePtyTranscriptProvider) is NOT
368
+ // transcriptAuthority:'provider', so without this it stays idle when a
369
+ // prompt is submitted from idle: the FSM never crosses generating→idle,
370
+ // detectStatusTransition's generating|waiting_approval→idle arm never
371
+ // runs, and agent:generating_completed is never emitted — the mesh
372
+ // coordinator leaves the task 'assigned' and the status-agnostic stall
373
+ // watchdog then false-fires task_stalled on the finished-but-idle
374
+ // session. Promoting this class to generating on turn-start makes the
375
+ // existing PTY-parsed final-assistant idle gate produce a real
376
+ // generating→idle completion edge. Same idle safety as above: applyIdle
377
+ // / finishResponse still own the actual idle transition.
378
+ const promoteOnTurnStart = this.provider.transcriptAuthority === 'provider'
379
+ || isPurePtyTranscriptProvider(this.provider);
380
+ if (promoteOnTurnStart && this.currentStatus !== 'waiting_approval') {
381
+ this.setStatus('generating', 'turn_started');
382
+ this.callbacks.onStatusChange();
383
+ }
307
384
  }
308
385
 
309
386
  /** Called when PTY exits */
310
387
  onPtyExit(): void {
311
388
  this.clearAllTimers();
312
389
  this.setStatus('stopped', 'pty_exit');
390
+ this.clearApprovalResolutionMemory();
313
391
  }
314
392
 
315
393
  /** Called when adapter starts up successfully */
@@ -382,6 +460,11 @@ export class CliStateEngine {
382
460
  this.activeModal = null;
383
461
  this.lastApprovalResolvedAt = Date.now();
384
462
  this.lastResolvedModalMessage = currentModalMessage;
463
+ // Snapshot the chrome-stripped screen at the moment of resolve — the
464
+ // baseline applyWaitingApproval's isStaleResolvedRepaint check compares
465
+ // against to tell a stale re-parse of THIS modal apart from a
466
+ // genuinely new one, regardless of how much wall-clock time passes.
467
+ this.lastApprovalResolvedContentSignature = this.computeApprovalContentSignature(snap);
385
468
  this.lastResolvedEntrySeq = this.approvalEntrySeq;
386
469
  this.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
387
470
  if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
@@ -499,6 +582,7 @@ export class CliStateEngine {
499
582
  this.pendingScriptStatusSince = 0;
500
583
  this.approvalResumeDeferSince = 0;
501
584
  this.approvalResumeDeferEpoch = -1;
585
+ this.clearApprovalResolutionMemory();
502
586
  }
503
587
 
504
588
  clearIdleFinishCandidate(reason: string): void {
@@ -679,7 +763,9 @@ export class CliStateEngine {
679
763
  if (!status) return;
680
764
 
681
765
  const prevStatus = this.currentStatus;
682
- const ctx: SettledEvalContext = { now, modal, status, parsedMessages, lastParsedAssistant, parsedStatus: parsedStatus || null, prevStatus };
766
+ const ctx: SettledEvalContext = {
767
+ now, modal, status, parsedMessages, lastParsedAssistant, parsedStatus: parsedStatus || null, prevStatus,
768
+ };
683
769
 
684
770
  if (!this.applyPendingScriptStatusDebounce(ctx)) return;
685
771
 
@@ -743,7 +829,7 @@ export class CliStateEngine {
743
829
  this.applyError(ctx, session);
744
830
  return;
745
831
  }
746
- if (status === 'waiting_approval') { this.applyWaitingApproval(ctx); return; }
832
+ if (status === 'waiting_approval') { this.applyWaitingApproval(ctx, snap); return; }
747
833
  if (status === 'generating') { this.applyGenerating(ctx); return; }
748
834
  if (status === 'idle') { this.applyIdle(ctx, snap, now); }
749
835
  }
@@ -801,7 +887,7 @@ export class CliStateEngine {
801
887
  this.callbacks.onStatusChange();
802
888
  }
803
889
 
804
- private applyWaitingApproval(ctx: SettledEvalContext): void {
890
+ private applyWaitingApproval(ctx: SettledEvalContext, snap: CliBufferSnapshot): void {
805
891
  const { modal } = ctx;
806
892
  this.clearIdleFinishCandidate('waiting_approval');
807
893
  const inCooldown = this.lastApprovalResolvedAt
@@ -892,6 +978,51 @@ export class CliStateEngine {
892
978
  }
893
979
  return;
894
980
  }
981
+ // (fix: kimi stale-approval re-latch, observed live on kimi-code
982
+ // v0.28.1/K3) A freshly-*parsed* modal is not proof the CLI is
983
+ // presenting it right now — parseApproval scans an accumulated
984
+ // raw-output window (recentOutputBuffer / window-around-question
985
+ // scope), which can still contain the text of an approval that was
986
+ // ALREADY resolved a moment ago and re-surface it on the very next
987
+ // settle pass, even though resolveModal() already wrote the key and
988
+ // cleared activeModal. Reproduced live: after approving a tool call,
989
+ // the FSM re-latched `waiting_approval` on the identical
990
+ // already-answered question with NO further genuinely new content
991
+ // ever arriving afterward — the dashboard stayed wedged on "waiting
992
+ // for approval" until the 5-minute maxResponse watchdog forced a
993
+ // recheck.
994
+ //
995
+ // An output-TIMESTAMP discriminator ("has any PTY byte arrived since
996
+ // the resolve") was tried first and found insufficient: a live
997
+ // standalone repro showed kimi's own idle-screen chrome (status bar,
998
+ // context meter, blank-line repaint) advances lastNonEmptyOutputAt
999
+ // within ~300ms of the resolve even though NOTHING approval-relevant
1000
+ // changed, so the guard stopped protecting almost immediately instead
1001
+ // of for as long as the staleness actually persisted (observed 30s+).
1002
+ //
1003
+ // Reject a recapture only when ALL of: (a) this is the first capture
1004
+ // since the last resolve (`!this.activeModal` — a genuinely repeated
1005
+ // approval re-enters this branch too, since resolveModal always nulls
1006
+ // activeModal), (b) the message text matches the one we just
1007
+ // resolved, AND (c) the chrome-stripped approval-context signature
1008
+ // (computeApprovalContentSignature — screen text with blank-line
1009
+ // padding and manifest-declared chrome removed) is UNCHANGED from
1010
+ // the signature captured at resolve time. (c) is the load-bearing,
1011
+ // content-based condition, deliberately not time-bounded: it keeps
1012
+ // rejecting for as long as nothing approval-relevant changes,
1013
+ // regardless of how many chrome-only repaints occur or how much
1014
+ // wall-clock time passes, and it stops rejecting the instant real
1015
+ // new content (tool output, a fresh conversational turn) appears.
1016
+ // This also keeps consecutive-approvals-with-identical-text (e.g.
1017
+ // two back-to-back "Allow Bash command?" prompts) working correctly:
1018
+ // a real follow-up approval necessarily means the CLI produced real
1019
+ // new output first (running the previous tool, then asking again),
1020
+ // which changes the signature regardless of shared message text.
1021
+ const isStaleResolvedRepaint = !this.activeModal && this.isStaleResolvedApproval(modal, snap);
1022
+ if (isStaleResolvedRepaint) {
1023
+ LOG.debug('CLI', `[${this.provider.type}] ignoring stale re-parsed approval matching the just-resolved modal (approval-context signature unchanged)`);
1024
+ return;
1025
+ }
895
1026
  this.modalLostAt = 0;
896
1027
  this.isWaitingForResponse = true;
897
1028
  this.setStatus('waiting_approval', 'script_detect');
@@ -1256,6 +1387,92 @@ export class CliStateEngine {
1256
1387
 
1257
1388
  // ─── Helpers ────────────────────────────────────────────────────────────
1258
1389
 
1390
+ /**
1391
+ * Derive a stable approval-context signature from the current screen,
1392
+ * with blank-line padding and any manifest-declared chrome stripped out.
1393
+ *
1394
+ * Generic by design — no kimi- or provider-specific hardcoding: it reads
1395
+ * whatever `tui.transcriptPty.chromePatterns` and `tui.spinner.patterns`
1396
+ * the ACTIVE provider's own manifest already declares (present on any
1397
+ * declarative-TUI provider; simply absent/empty for scripted providers,
1398
+ * in which case this degrades to blank-line stripping only — never worse
1399
+ * than comparing the raw screen). Those pattern lists exist precisely to
1400
+ * name "known volatile repaint noise" (status bar, context meter, spinner
1401
+ * ticks, banners) — reusing them here means the SAME declared knowledge
1402
+ * that governs transcript-chrome stripping also governs staleness
1403
+ * detection, instead of re-encoding provider knowledge into daemon-core.
1404
+ *
1405
+ * Deliberately NOT a hash of the whole screen/buffer: an ordinary TUI
1406
+ * repaints its footer/status/context-meter chrome continuously even while
1407
+ * genuinely idle, so a raw whole-screen or whole-buffer fingerprint (or a
1408
+ * mere "did any bytes arrive" timestamp) changes on every repaint tick
1409
+ * regardless of whether anything approval-relevant actually happened.
1410
+ * Stripping the declared chrome first yields a signature that only
1411
+ * changes when the surrounding conversation/tool-output content itself
1412
+ * changes — exactly the discriminator applyWaitingApproval's
1413
+ * isStaleResolvedRepaint check needs.
1414
+ */
1415
+ private computeApprovalContentSignature(snap: ApprovalSignatureSnapshot): string {
1416
+ const screenText = snap.screenText || snap.accumulatedBuffer || '';
1417
+ if (!screenText) return '';
1418
+ const tui = (this.provider as { tui?: Record<string, any> }).tui;
1419
+ const patternSpecs: Array<{ regex?: unknown; flags?: unknown }> = [
1420
+ ...(Array.isArray(tui?.transcriptPty?.chromePatterns) ? tui!.transcriptPty.chromePatterns : []),
1421
+ ...(Array.isArray(tui?.spinner?.patterns) ? tui!.spinner.patterns : []),
1422
+ ];
1423
+ const chromeRegexes: RegExp[] = [];
1424
+ for (const spec of patternSpecs) {
1425
+ if (spec && typeof spec.regex === 'string') {
1426
+ try {
1427
+ chromeRegexes.push(new RegExp(spec.regex, typeof spec.flags === 'string' ? spec.flags : ''));
1428
+ } catch {
1429
+ // Ignore an unparseable manifest regex — signature just skips that filter.
1430
+ }
1431
+ }
1432
+ }
1433
+ const kept: string[] = [];
1434
+ for (const rawLine of screenText.split('\n')) {
1435
+ const line = rawLine.trim();
1436
+ if (!line) continue; // strip blank-line repaint padding
1437
+ if (chromeRegexes.some((re) => re.test(line))) continue; // strip declared chrome
1438
+ kept.push(line);
1439
+ }
1440
+ return kept.join('\n');
1441
+ }
1442
+
1443
+ /**
1444
+ * True when `modal` is a stale re-parse of an already-resolved approval:
1445
+ * same message text, and the chrome-stripped approval-context signature
1446
+ * of `snap` is unchanged from the signature captured at resolve time.
1447
+ *
1448
+ * Public and reused verbatim by BOTH the settled-eval capture path
1449
+ * (applyWaitingApproval, below) and any OUTSIDE re-parse the adapter
1450
+ * performs independently of the settle loop — e.g. provider-cli-adapter's
1451
+ * getStatus()/getDebugState() startup-gate modal detection, which reads
1452
+ * `recentOutputBuffer` directly while `startupParseGate` is open and can
1453
+ * re-surface the same already-resolved modal before the gate closes.
1454
+ * Centralizing the discriminator here means there is exactly ONE
1455
+ * definition of "stale" for the whole session — no divergent duplicate
1456
+ * heuristic re-implemented per call site.
1457
+ */
1458
+ isStaleResolvedApproval(modal: { message: string; buttons: string[] } | null, snap: ApprovalSignatureSnapshot): boolean {
1459
+ if (!modal) return false;
1460
+ const normalizedMessage = typeof modal.message === 'string' ? modal.message.trim() : '';
1461
+ if (!normalizedMessage) return false;
1462
+ return this.lastApprovalResolvedAt > 0
1463
+ && normalizedMessage === this.lastResolvedModalMessage
1464
+ && this.computeApprovalContentSignature(snap) === this.lastApprovalResolvedContentSignature;
1465
+ }
1466
+
1467
+ /** Clear all resolve-time approval bookkeeping (message, timestamp, content
1468
+ * signature) — called at turn/session boundaries so a next-turn or
1469
+ * next-session approval is never compared against stale prior state. */
1470
+ private clearApprovalResolutionMemory(): void {
1471
+ this.lastApprovalResolvedAt = 0;
1472
+ this.lastResolvedModalMessage = '';
1473
+ this.lastApprovalResolvedContentSignature = '';
1474
+ }
1475
+
1259
1476
  /**
1260
1477
  * Schedule one more settled evaluation while pinned to `waiting_approval`
1261
1478
  * with no actionable modal. The settled FSM normally only re-runs on new
@@ -39,6 +39,7 @@ import {
39
39
  compactPromptText,
40
40
  estimatePromptDisplayLines,
41
41
  extractPromptRetrySnippet,
42
+ isPurePtyTranscriptProvider,
42
43
  listCliScriptNames,
43
44
  normalizePromptText,
44
45
  normalizeScreenSnapshot,
@@ -456,12 +457,7 @@ export class ProviderCliAdapter implements CliAdapter {
456
457
  * provider-owned) so no other provider's turn-scoped parse changes.
457
458
  */
458
459
  private parsesFullPtyTranscriptFromBuffer(): boolean {
459
- if (this.providerOwnsTranscript()) return false;
460
- // nativeHistory is a top-level provider field not surfaced on
461
- // CliProviderModule; read it via the same structural cast used for `tui`.
462
- if ((this.provider as { nativeHistory?: unknown }).nativeHistory) return false;
463
- const transcriptPty = (this.provider.tui as { transcriptPty?: { scope?: unknown } } | undefined)?.transcriptPty;
464
- return transcriptPty?.scope === 'buffer';
460
+ return isPurePtyTranscriptProvider(this.provider);
465
461
  }
466
462
 
467
463
  /**
@@ -707,8 +703,10 @@ export class ProviderCliAdapter implements CliAdapter {
707
703
  }
708
704
  });
709
705
 
710
- this.ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
711
- LOG.info('CLI', `[${this.cliType}] Exit code ${exitCode}`);
706
+ this.ptyProcess.onExit(({ exitCode, signal }: { exitCode: number | null; signal?: number | null }) => {
707
+ // Preserve the unknown case: a null exitCode (signal-terminated or
708
+ // otherwise unreported) is logged as "unknown", never as exit 0.
709
+ LOG.info('CLI', `[${this.cliType}] Exit code ${exitCode === null || exitCode === undefined ? 'unknown' : exitCode}${signal ? ` (signal ${signal})` : ''}`);
712
710
  this.flushPendingOutputParse();
713
711
  this.ptyProcess = null;
714
712
  this.engine.onPtyExit();
@@ -1072,7 +1070,19 @@ export class ProviderCliAdapter implements CliAdapter {
1072
1070
 
1073
1071
  getStatus(options: { allowParse?: boolean } = {}): CliSessionStatus {
1074
1072
  const allowParse = options.allowParse !== false;
1075
- const startupModal = allowParse && this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
1073
+ let startupModal = allowParse && this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
1074
+ // (fix: kimi startup-gate stale-approval bypass) startupParseGate can
1075
+ // still be open by the time a real approval is requested AND resolved
1076
+ // — this ad-hoc parse of recentOutputBuffer runs independently of the
1077
+ // settled-eval loop and previously had no staleness protection at all,
1078
+ // so it kept re-surfacing the identical already-resolved modal for as
1079
+ // long as its text remained anywhere in the rolling buffer, bypassing
1080
+ // engine.activeModal (which resolveModal() had already correctly
1081
+ // cleared). Reuse the SAME discriminator the settle loop uses instead
1082
+ // of inventing a second one here.
1083
+ if (startupModal && this.engine.isStaleResolvedApproval(startupModal, { screenText: this.terminalScreen.getText(), accumulatedBuffer: this.accumulatedBuffer })) {
1084
+ startupModal = null;
1085
+ }
1076
1086
  const startupDetectedStatus = allowParse && this.startupParseGate && !startupModal
1077
1087
  ? this.runDetectStatus(this.recentOutputBuffer || this.terminalScreen.getText())
1078
1088
  : null;
@@ -1179,7 +1189,20 @@ export class ProviderCliAdapter implements CliAdapter {
1179
1189
  // and let a later evaluate find the complete modal.
1180
1190
  const buttonsOk = liveModal && Array.isArray(liveModal.buttons)
1181
1191
  && liveModal.buttons.some((b: any) => typeof b === 'string' && b.trim());
1182
- if (liveModal && buttonsOk) {
1192
+ // (fix: kimi live-detect stale-approval bypass) This fallback is
1193
+ // NOT gated by startupParseGate — it runs on every getStatus()
1194
+ // call (including the periodic background status heartbeat)
1195
+ // for as long as isWaitingForResponse stays true, which for a
1196
+ // provider that keeps "thinking" after the approved tool call
1197
+ // can be the whole rest of the turn. Its own unguarded re-parse
1198
+ // of recentOutputBuffer/terminalScreen previously kept
1199
+ // re-writing engine.activeModal directly — bypassing setStatus
1200
+ // (so rawStatus never even flipped) and bypassing
1201
+ // applyWaitingApproval's staleness guard entirely, silently
1202
+ // re-corrupting the engine's own state moments after
1203
+ // resolveModal() had correctly cleared it. Reuse the same
1204
+ // engine-owned discriminator here too.
1205
+ if (liveModal && buttonsOk && !this.engine.isStaleResolvedApproval(liveModal, { screenText: this.terminalScreen.getText(), accumulatedBuffer: this.accumulatedBuffer })) {
1183
1206
  effectiveModal = liveModal;
1184
1207
  if (!this.engine.activeModal) this.engine.activeModal = liveModal;
1185
1208
  }
@@ -2474,7 +2497,14 @@ export class ProviderCliAdapter implements CliAdapter {
2474
2497
 
2475
2498
  getDebugState(): Record<string, any> {
2476
2499
  const screenText = sanitizeTerminalText(this.terminalScreen.getText());
2477
- const startupModal = this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
2500
+ let startupModal = this.startupParseGate ? this.runParseApproval(this.recentOutputBuffer) : null;
2501
+ // (fix: kimi startup-gate stale-approval bypass) See the matching
2502
+ // comment in getStatus() — this ad-hoc startup-gate parse bypassed
2503
+ // engine.activeModal's staleness protection entirely. Reuse the same
2504
+ // engine-owned discriminator rather than duplicating it here.
2505
+ if (startupModal && this.engine.isStaleResolvedApproval(startupModal, { screenText: this.terminalScreen.getText(), accumulatedBuffer: this.accumulatedBuffer })) {
2506
+ startupModal = null;
2507
+ }
2478
2508
  const startupDetectedStatus = this.startupParseGate && !startupModal
2479
2509
  ? this.runDetectStatus(this.recentOutputBuffer || screenText)
2480
2510
  : null;