@adhdev/daemon-core 0.9.54 → 0.9.55

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.
@@ -358,6 +358,8 @@ export interface ProviderModule {
358
358
  sendKey?: string;
359
359
  /** How the CLI adapter decides when to submit typed input */
360
360
  submitStrategy?: 'wait_for_echo' | 'immediate';
361
+ /** If true, typed input must echo on the PTY screen before the adapter sends Enter. */
362
+ requirePromptEchoBeforeSubmit?: boolean;
361
363
  /** Keep this provider out of the upstream auto-updated bundle */
362
364
  /** @deprecated Machine-level provider source policy now lives in config.providerSourceMode. Local overrides shadow upstream by root precedence and should not rely on provider-level disableUpstream. */
363
365
  disableUpstream?: boolean;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/session-host-core",
3
- "version": "0.9.54",
3
+ "version": "0.9.55",
4
4
  "description": "ADHDev local session host core \u2014 session registry, protocol, buffers",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.54",
3
+ "version": "0.9.55",
4
4
  "description": "ADHDev daemon core \u2014 CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -35,6 +35,7 @@ import {
35
35
  import { loadConfig } from '../config/config.js';
36
36
  import type { PtyTransportFactory } from '../cli-adapters/pty-transport.js';
37
37
  import type { IdeProviderInstance } from '../providers/ide-provider-instance.js';
38
+ import { createDefaultGitCommandServices } from '../git/git-commands.js';
38
39
 
39
40
  // ─── Init Config ───
40
41
 
@@ -78,6 +79,9 @@ export interface DaemonInitConfig {
78
79
  statusInstanceId?: string;
79
80
  statusVersion?: string;
80
81
  statusDaemonMode?: boolean;
82
+
83
+ /** Fired before send_chat is dispatched — used for turn snapshot hooks */
84
+ onBeforeSendChat?: (params: { workspace: string; sessionId: string }) => void;
81
85
  }
82
86
 
83
87
  // ─── Result ───
@@ -256,6 +260,7 @@ export async function initDaemonComponents(config: DaemonInitConfig): Promise<Da
256
260
  providerLoader,
257
261
  instanceManager,
258
262
  sessionRegistry,
263
+ gitCommandServices: createDefaultGitCommandServices(),
259
264
  onProviderSettingChanged: async (providerType) => {
260
265
  await refreshProviderAvailability(providerType);
261
266
  config.onStatusChange?.();
@@ -264,6 +269,7 @@ export async function initDaemonComponents(config: DaemonInitConfig): Promise<Da
264
269
  await refreshProviderAvailability();
265
270
  config.onStatusChange?.();
266
271
  },
272
+ onBeforeSendChat: config.onBeforeSendChat,
267
273
  });
268
274
 
269
275
  // 8. AgentStreamManager
@@ -598,6 +598,7 @@ export class ProviderCliAdapter implements CliAdapter {
598
598
  private readonly sendDelayMs: number;
599
599
  private readonly sendKey: string;
600
600
  private readonly submitStrategy: 'wait_for_echo' | 'immediate';
601
+ private readonly requirePromptEchoBeforeSubmit: boolean;
601
602
  private static readonly SCRIPT_STATUS_DEBOUNCE_MS = 3000;
602
603
 
603
604
  constructor(
@@ -620,6 +621,7 @@ export class ProviderCliAdapter implements CliAdapter {
620
621
  this.sendDelayMs = resolvedConfig.sendDelayMs;
621
622
  this.sendKey = resolvedConfig.sendKey;
622
623
  this.submitStrategy = resolvedConfig.submitStrategy;
624
+ this.requirePromptEchoBeforeSubmit = resolvedConfig.requirePromptEchoBeforeSubmit;
623
625
  this.providerResolutionMeta = resolvedConfig.providerResolutionMeta;
624
626
 
625
627
  // Scripts are required — loaded by ProviderLoader via compatibility array
@@ -2392,6 +2394,22 @@ export class ProviderCliAdapter implements CliAdapter {
2392
2394
  }
2393
2395
 
2394
2396
  if (elapsed >= state.maxEchoWaitMs) {
2397
+ const diagnostic = {
2398
+ elapsed,
2399
+ maxEchoWaitMs: state.maxEchoWaitMs,
2400
+ submitDelayMs: state.submitDelayMs,
2401
+ promptSnippet: state.normalizedPromptSnippet,
2402
+ requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
2403
+ screenText: summarizeCliTraceText(screenText, 1000),
2404
+ };
2405
+ this.recordTrace('submit_echo_missing', diagnostic);
2406
+ if (this.requirePromptEchoBeforeSubmit) {
2407
+ const message = `${this.cliName} prompt echo was not observed on the PTY screen before submit`;
2408
+ LOG.warn('CLI', `[${this.cliType}] ${message} elapsed=${elapsed}ms maxEchoWaitMs=${state.maxEchoWaitMs} screen=${JSON.stringify(diagnostic.screenText).slice(0, 240)}`);
2409
+ completion.rejectOnce(new Error(message));
2410
+ return;
2411
+ }
2412
+ LOG.warn('CLI', `[${this.cliType}] prompt echo was not observed before submit; sending submit key anyway elapsed=${elapsed}ms maxEchoWaitMs=${state.maxEchoWaitMs}`);
2395
2413
  this.submitSendKey(state, completion);
2396
2414
  return;
2397
2415
  }
@@ -2880,6 +2898,7 @@ export class ProviderCliAdapter implements CliAdapter {
2880
2898
  sendDelayMs: this.sendDelayMs,
2881
2899
  sendKey: this.sendKey,
2882
2900
  submitStrategy: this.submitStrategy,
2901
+ requirePromptEchoBeforeSubmit: this.requirePromptEchoBeforeSubmit,
2883
2902
  submitPendingUntil: this.submitPendingUntil,
2884
2903
  responseSettleIgnoreUntil: this.responseSettleIgnoreUntil,
2885
2904
  resizeSuppressUntil: this.resizeSuppressUntil,
@@ -25,6 +25,7 @@ export interface ResolvedCliAdapterConfig {
25
25
  sendDelayMs: number;
26
26
  sendKey: string;
27
27
  submitStrategy: 'wait_for_echo' | 'immediate';
28
+ requirePromptEchoBeforeSubmit: boolean;
28
29
  providerResolutionMeta: ProviderResolutionMeta;
29
30
  }
30
31
  export declare function resolveCliAdapterConfig(provider: CliProviderModule): ResolvedCliAdapterConfig;
@@ -29,6 +29,7 @@ export interface ResolvedCliAdapterConfig {
29
29
  sendDelayMs: number;
30
30
  sendKey: string;
31
31
  submitStrategy: 'wait_for_echo' | 'immediate';
32
+ requirePromptEchoBeforeSubmit: boolean;
32
33
  providerResolutionMeta: ProviderResolutionMeta;
33
34
  }
34
35
 
@@ -55,6 +56,7 @@ export function resolveCliAdapterConfig(provider: CliProviderModule): ResolvedCl
55
56
  ? provider.sendKey
56
57
  : '\r',
57
58
  submitStrategy: provider.submitStrategy === 'immediate' ? 'immediate' : 'wait_for_echo',
59
+ requirePromptEchoBeforeSubmit: provider.requirePromptEchoBeforeSubmit === true,
58
60
  providerResolutionMeta: {
59
61
  type: provider.type,
60
62
  name: provider.name,
@@ -104,6 +104,8 @@ export interface CliProviderModule {
104
104
  sendDelayMs?: number;
105
105
  sendKey?: string;
106
106
  submitStrategy?: 'wait_for_echo' | 'immediate';
107
+ /** Require the typed prompt to be visible on the PTY screen before sending Enter. */
108
+ requirePromptEchoBeforeSubmit?: boolean;
107
109
  /** Allow sending another prompt while the CLI is still generating so users can intervene mid-turn. */
108
110
  allowInputDuringGeneration?: boolean;
109
111
  scripts?: CliScripts;
@@ -122,6 +122,8 @@ export interface CliProviderModule {
122
122
  sendDelayMs?: number;
123
123
  sendKey?: string;
124
124
  submitStrategy?: 'wait_for_echo' | 'immediate';
125
+ /** Require the typed prompt to be visible on the PTY screen before sending Enter. */
126
+ requirePromptEchoBeforeSubmit?: boolean;
125
127
  /** Allow sending another prompt while the CLI is still generating so users can intervene mid-turn. */
126
128
  allowInputDuringGeneration?: boolean;
127
129
  /** When provider-owned, daemon treats provider parser output as canonical transcript authority. */
@@ -50,6 +50,8 @@ export interface CommandContext {
50
50
  onProviderSettingChanged?: (providerType: string, key: string, value: any) => Promise<void> | void;
51
51
  onProviderSourceConfigChanged?: () => Promise<void> | void;
52
52
  gitCommandServices?: GitCommandServices;
53
+ /** Fired synchronously before send_chat is dispatched; fire-and-forget for callers */
54
+ onBeforeSendChat?: (params: { workspace: string; sessionId: string }) => void;
53
55
  }
54
56
 
55
57
  /**
@@ -424,6 +426,20 @@ export class DaemonCommandHandler implements CommandHelpers {
424
426
  }
425
427
  }
426
428
 
429
+ if (cmd === 'send_chat' && this._ctx.onBeforeSendChat) {
430
+ const sessionId = this._currentRoute.session?.sessionId;
431
+ const workspace = sessionId
432
+ ? (this._ctx.instanceManager?.getInstance(sessionId) as any)?.getState?.()?.workspace
433
+ : undefined;
434
+ if (workspace && sessionId) {
435
+ try {
436
+ this._ctx.onBeforeSendChat({ workspace, sessionId });
437
+ } catch {
438
+ // hook must not block send_chat
439
+ }
440
+ }
441
+ }
442
+
427
443
  try {
428
444
  result = await this.dispatch(cmd, args);
429
445
  this.logCommandEnd(cmd, result, startedAt);
@@ -41,6 +41,18 @@ export interface GitLogResult extends GitRepoIdentity {
41
41
  lastCheckedAt: number;
42
42
  }
43
43
 
44
+ export interface GitCheckpointResult extends GitRepoIdentity {
45
+ commit: string;
46
+ message: string;
47
+ lastCheckedAt: number;
48
+ }
49
+
50
+ export interface GitStashPushResult extends GitRepoIdentity {
51
+ stashRef: string;
52
+ message: string;
53
+ lastCheckedAt: number;
54
+ }
55
+
44
56
  export interface GitCommandServices {
45
57
  getStatus?: (params: { workspace: string }) => Promise<GitRepoStatus> | GitRepoStatus;
46
58
  getDiffSummary?: (params: { workspace: string; staged?: boolean }) => Promise<GitDiffSummary> | GitDiffSummary;
@@ -63,6 +75,19 @@ export interface GitCommandServices {
63
75
  since?: string;
64
76
  until?: string;
65
77
  }) => Promise<GitLogResult> | GitLogResult;
78
+ checkpoint?: (params: {
79
+ workspace: string;
80
+ message: string;
81
+ includeUntracked?: boolean;
82
+ }) => Promise<GitCheckpointResult> | GitCheckpointResult;
83
+ stashPush?: (params: {
84
+ workspace: string;
85
+ message: string;
86
+ includeUntracked?: boolean;
87
+ }) => Promise<GitStashPushResult> | GitStashPushResult;
88
+ stashPop?: (params: { workspace: string; stashRef?: string }) => Promise<void>;
89
+ checkoutFiles?: (params: { workspace: string; paths: string[] }) => Promise<{ checkedOut: string[] }>;
90
+ getRemoteUrl?: (params: { workspace: string; remote?: string }) => Promise<{ remoteUrl: string; remote: string }>;
66
91
  }
67
92
 
68
93
  type GitCommandFailure = {
@@ -77,7 +102,12 @@ type GitCommandSuccess =
77
102
  | { success: true; diff: GitFileDiff }
78
103
  | { success: true; snapshot: GitSnapshot }
79
104
  | { success: true; compare: GitSnapshotCompareSummary }
80
- | { success: true; log: GitLogResult };
105
+ | { success: true; log: GitLogResult }
106
+ | { success: true; checkpoint: GitCheckpointResult }
107
+ | { success: true; stash: GitStashPushResult }
108
+ | { success: true; stashPopped: true }
109
+ | { success: true; checkedOut: string[] }
110
+ | { success: true; remoteUrl: string; remote: string };
81
111
 
82
112
  export type GitCommandResult = GitCommandSuccess | GitCommandFailure;
83
113
 
@@ -92,13 +122,7 @@ const GIT_COMMAND_NAMES = new Set<GitCommandName>([
92
122
  'git_stash_push',
93
123
  'git_stash_pop',
94
124
  'git_checkout_files',
95
- ]);
96
-
97
- const MUTATING_COMMAND_NAMES = new Set<GitCommandName>([
98
- 'git_checkpoint',
99
- 'git_stash_push',
100
- 'git_stash_pop',
101
- 'git_checkout_files',
125
+ 'git_remote_url',
102
126
  ]);
103
127
 
104
128
  const SNAPSHOT_REASONS = new Set<GitSnapshotReason>([
@@ -146,6 +170,11 @@ export function createDefaultGitCommandServices(): GitCommandServices {
146
170
  }),
147
171
  compareSnapshots: ({ beforeSnapshotId, afterSnapshotId }) => defaultSnapshotStore.compare(beforeSnapshotId, afterSnapshotId),
148
172
  getLog: ({ workspace, limit, path: filePath, since, until }) => getGitLog(workspace, { limit, path: filePath, since, until }),
173
+ checkpoint: async ({ workspace, message, includeUntracked = false }) => gitCheckpoint(workspace, message, includeUntracked),
174
+ stashPush: async ({ workspace, message, includeUntracked = false }) => gitStashPush(workspace, message, includeUntracked),
175
+ stashPop: async ({ workspace, stashRef }) => gitStashPop(workspace, stashRef),
176
+ checkoutFiles: async ({ workspace, paths }) => gitCheckoutFiles(workspace, paths),
177
+ getRemoteUrl: async ({ workspace, remote = 'origin' }) => gitGetRemoteUrl(workspace, remote),
149
178
  };
150
179
  }
151
180
 
@@ -240,10 +269,6 @@ export async function handleGitCommand(
240
269
  return failure('invalid_args', `Unknown Git command: ${command}`);
241
270
  }
242
271
 
243
- if (MUTATING_COMMAND_NAMES.has(command)) {
244
- return failure('invalid_args', `${command} is not implemented in daemon-core read-only Git routing`);
245
- }
246
-
247
272
  const workspaceResult = validateWorkspace(args);
248
273
  if ('success' in workspaceResult) return workspaceResult;
249
274
  const { workspace } = workspaceResult;
@@ -310,11 +335,183 @@ export async function handleGitCommand(
310
335
  return 'success' in log ? log : { success: true, log };
311
336
  }
312
337
 
338
+ case 'git_checkpoint': {
339
+ if (!services.checkpoint) return serviceNotImplemented(command);
340
+ const msg = validateMutatingMessage(args?.message);
341
+ if (typeof msg !== 'string') return msg;
342
+ const includeUntracked = Boolean(args?.includeUntracked);
343
+ const checkpoint = await runService(() => services.checkpoint!({ workspace, message: msg, includeUntracked }));
344
+ return 'success' in checkpoint ? checkpoint : { success: true, checkpoint };
345
+ }
346
+
347
+ case 'git_stash_push': {
348
+ if (!services.stashPush) return serviceNotImplemented(command);
349
+ const msg = validateMutatingMessage(args?.message);
350
+ if (typeof msg !== 'string') return msg;
351
+ const includeUntracked = Boolean(args?.includeUntracked);
352
+ const stash = await runService(() => services.stashPush!({ workspace, message: msg, includeUntracked }));
353
+ return 'success' in stash ? stash : { success: true, stash };
354
+ }
355
+
356
+ case 'git_stash_pop': {
357
+ if (!services.stashPop) return serviceNotImplemented(command);
358
+ const stashRef = optionalString(args?.stashRef);
359
+ if (stashRef !== undefined && !/^stash@\{\d+\}$/.test(stashRef)) {
360
+ return failure('invalid_args', 'stashRef must match stash@{N} format');
361
+ }
362
+ const popResult = await runService(() => services.stashPop!({ workspace, stashRef }));
363
+ if (popResult !== undefined && 'success' in (popResult as object)) return popResult as GitCommandFailure;
364
+ return { success: true, stashPopped: true };
365
+ }
366
+
367
+ case 'git_checkout_files': {
368
+ if (!services.checkoutFiles) return serviceNotImplemented(command);
369
+ const paths = args?.paths;
370
+ if (!Array.isArray(paths) || paths.length === 0) {
371
+ return failure('invalid_args', 'paths must be a non-empty array');
372
+ }
373
+ if (paths.length > 50) {
374
+ return failure('invalid_args', 'paths array exceeds maximum of 50 entries');
375
+ }
376
+ const checkoutResult = await runService(() => services.checkoutFiles!({ workspace, paths }));
377
+ return 'success' in checkoutResult ? checkoutResult : { success: true, checkedOut: (checkoutResult as { checkedOut: string[] }).checkedOut };
378
+ }
379
+
380
+ case 'git_remote_url': {
381
+ if (!services.getRemoteUrl) return serviceNotImplemented(command);
382
+ const remote = typeof args?.remote === 'string' && args.remote.trim() ? args.remote.trim() : 'origin';
383
+ const remoteResult = await runService(() => services.getRemoteUrl!({ workspace, remote }));
384
+ if ('success' in remoteResult) return remoteResult;
385
+ return { success: true, remoteUrl: remoteResult.remoteUrl, remote: remoteResult.remote };
386
+ }
387
+
313
388
  default:
314
389
  return failure('invalid_args', `Unknown Git command: ${command}`);
315
390
  }
316
391
  }
317
392
 
393
+ function validateMutatingMessage(value: unknown): string | GitCommandFailure {
394
+ if (typeof value !== 'string' || !value.trim()) {
395
+ return failure('invalid_args', 'message must be a non-empty string');
396
+ }
397
+ const msg = value.trim();
398
+ if (msg.length > 200) {
399
+ return failure('invalid_args', 'message must be 200 characters or fewer');
400
+ }
401
+ return msg;
402
+ }
403
+
404
+ async function gitCheckpoint(
405
+ workspace: string,
406
+ message: string,
407
+ includeUntracked: boolean,
408
+ ): Promise<GitCheckpointResult> {
409
+ const repo = await resolveGitRepository(workspace);
410
+ const repoRoot = repo.repoRoot!;
411
+
412
+ const statusResult = await getGitRepoStatus(workspace);
413
+ if (statusResult.hasConflicts) {
414
+ throw new GitCommandError('conflict', 'Repository has conflicts — resolve before checkpointing');
415
+ }
416
+
417
+ const addArgs = includeUntracked ? ['-A'] : ['-u'];
418
+ await runGit(repo, ['add', ...addArgs], { cwd: repoRoot });
419
+
420
+ const fullMsg = `adhdev: checkpoint ${message}`;
421
+ let commitSha: string;
422
+ try {
423
+ await runGit(repo, ['commit', '-m', fullMsg], { cwd: repoRoot });
424
+ const revResult = await runGit(repo, ['rev-parse', 'HEAD'], { cwd: repoRoot });
425
+ commitSha = revResult.stdout.trim();
426
+ } catch (err: any) {
427
+ const output = (err?.stdout || '') + (err?.stderr || '');
428
+ if (/nothing to commit/i.test(output)) {
429
+ throw new GitCommandError('git_command_failed', 'Nothing to commit');
430
+ }
431
+ throw err;
432
+ }
433
+
434
+ return {
435
+ workspace: repo.workspace,
436
+ repoRoot,
437
+ isGitRepo: true,
438
+ commit: commitSha,
439
+ message: fullMsg,
440
+ lastCheckedAt: Date.now(),
441
+ };
442
+ }
443
+
444
+ async function gitStashPush(
445
+ workspace: string,
446
+ message: string,
447
+ includeUntracked: boolean,
448
+ ): Promise<GitStashPushResult> {
449
+ const repo = await resolveGitRepository(workspace);
450
+ const repoRoot = repo.repoRoot!;
451
+
452
+ const stashArgs = ['stash', 'push', '-m', message];
453
+ if (includeUntracked) stashArgs.push('--include-untracked');
454
+
455
+ const result = await runGit(repo, stashArgs, { cwd: repoRoot });
456
+ if (/No local changes to save/i.test(result.stdout + result.stderr)) {
457
+ throw new GitCommandError('git_command_failed', 'Nothing to stash');
458
+ }
459
+
460
+ return {
461
+ workspace: repo.workspace,
462
+ repoRoot,
463
+ isGitRepo: true,
464
+ stashRef: 'stash@{0}',
465
+ message,
466
+ lastCheckedAt: Date.now(),
467
+ };
468
+ }
469
+
470
+ async function gitStashPop(workspace: string, stashRef?: string): Promise<void> {
471
+ const repo = await resolveGitRepository(workspace);
472
+ const repoRoot = repo.repoRoot!;
473
+
474
+ const popArgs = stashRef ? ['stash', 'pop', stashRef] : ['stash', 'pop'];
475
+ await runGit(repo, popArgs, { cwd: repoRoot });
476
+ }
477
+
478
+ async function gitCheckoutFiles(workspace: string, paths: string[]): Promise<{ checkedOut: string[] }> {
479
+ const repo = await resolveGitRepository(workspace);
480
+ const repoRoot = repo.repoRoot!;
481
+
482
+ const normalizedPaths: string[] = [];
483
+ for (const p of paths) {
484
+ if (typeof p !== 'string' || !p.trim() || p.includes('\0')) {
485
+ throw new GitCommandError('invalid_args', `Invalid path: ${String(p)}`);
486
+ }
487
+ if (path.isAbsolute(p)) {
488
+ throw new GitCommandError('invalid_args', `Path must be repository-relative, not absolute: ${p}`);
489
+ }
490
+ const normalized = path.normalize(p.trim()).split(path.sep).join('/');
491
+ if (normalized.startsWith('../') || normalized === '..') {
492
+ throw new GitCommandError('path_outside_repo', `Path is outside repository root: ${p}`);
493
+ }
494
+ const absolutePath = path.resolve(repoRoot, normalized);
495
+ if (!isPathInside(repoRoot, absolutePath)) {
496
+ throw new GitCommandError('path_outside_repo', `Path is outside repository root: ${p}`);
497
+ }
498
+ normalizedPaths.push(normalized);
499
+ }
500
+
501
+ await runGit(repo, ['checkout', '--', ...normalizedPaths], { cwd: repoRoot });
502
+ return { checkedOut: normalizedPaths };
503
+ }
504
+
505
+ async function gitGetRemoteUrl(workspace: string, remote: string): Promise<{ remoteUrl: string; remote: string }> {
506
+ const repo = await resolveGitRepository(workspace);
507
+ const result = await runGit(repo, ['remote', 'get-url', remote], { cwd: repo.repoRoot! });
508
+ const remoteUrl = result.stdout.trim();
509
+ if (!remoteUrl) {
510
+ throw new GitCommandError('git_command_failed', `Remote '${remote}' has no URL`);
511
+ }
512
+ return { remoteUrl, remote };
513
+ }
514
+
318
515
  function formatOptionalGitLogRangeArg(flag: '--since' | '--until', value: string | undefined): string[] {
319
516
  return value ? [`${flag}=${value}`] : [];
320
517
  }
@@ -150,4 +150,5 @@ export type GitCommandName =
150
150
  | 'git_checkpoint'
151
151
  | 'git_stash_push'
152
152
  | 'git_stash_pop'
153
- | 'git_checkout_files';
153
+ | 'git_checkout_files'
154
+ | 'git_remote_url';
package/src/git/index.ts CHANGED
@@ -70,3 +70,6 @@ export type {
70
70
  GitLogEntry,
71
71
  GitLogResult,
72
72
  } from './git-commands.js';
73
+
74
+ export { TurnSnapshotTracker } from './turn-snapshot-tracker.js';
75
+ export type { TurnCompletedCallback } from './turn-snapshot-tracker.js';
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Tracks agent session status transitions and fires snapshot callbacks on turn completion.
3
+ * "Busy" = streaming | waiting_approval
4
+ * "Completed" = idle | error (transition from busy)
5
+ */
6
+
7
+ export type TurnCompletedCallback = (params: { sessionId: string; workspace: string }) => void
8
+
9
+ const BUSY_STATUSES = new Set(['streaming', 'waiting_approval'])
10
+ const TERMINAL_STATUSES = new Set(['idle', 'error'])
11
+
12
+ export class TurnSnapshotTracker {
13
+ private lastStatus = new Map<string, string>()
14
+ private onTurnCompleted: TurnCompletedCallback
15
+
16
+ constructor(onTurnCompleted: TurnCompletedCallback) {
17
+ this.onTurnCompleted = onTurnCompleted
18
+ }
19
+
20
+ record(sessionId: string, status: string, workspace: string | null | undefined): void {
21
+ const prev = this.lastStatus.get(sessionId)
22
+ this.lastStatus.set(sessionId, status)
23
+ if (workspace && prev && BUSY_STATUSES.has(prev) && TERMINAL_STATUSES.has(status)) {
24
+ this.onTurnCompleted({ sessionId, workspace })
25
+ }
26
+ }
27
+
28
+ forget(sessionId: string): void {
29
+ this.lastStatus.delete(sessionId)
30
+ }
31
+ }
@@ -339,6 +339,14 @@ export interface ProviderModule {
339
339
  sessionProbe?: ProviderSessionProbe;
340
340
  /** Allow sending another prompt while the CLI is still generating so users can intervene mid-turn. */
341
341
  allowInputDuringGeneration?: boolean;
342
+ /** Delay before submitting typed CLI input (provider-specific TUI tuning) */
343
+ sendDelayMs?: number;
344
+ /** Submit key used after typing into CLI PTY (default: carriage return) */
345
+ sendKey?: string;
346
+ /** How the CLI adapter decides when to submit typed input */
347
+ submitStrategy?: 'wait_for_echo' | 'immediate';
348
+ /** If true, typed input must echo on the PTY screen before the adapter sends Enter. */
349
+ requirePromptEchoBeforeSubmit?: boolean;
342
350
  /** Approval button priority hints used when auto-approve must pick a positive action */
343
351
  approvalPositiveHints?: string[];
344
352
  scripts?: ProviderScripts;
@@ -450,6 +450,8 @@ export interface ProviderModule {
450
450
  sendKey?: string;
451
451
  /** How the CLI adapter decides when to submit typed input */
452
452
  submitStrategy?: 'wait_for_echo' | 'immediate';
453
+ /** If true, typed input must echo on the PTY screen before the adapter sends Enter. */
454
+ requirePromptEchoBeforeSubmit?: boolean;
453
455
  /** Keep this provider out of the upstream auto-updated bundle */
454
456
  /** @deprecated Machine-level provider source policy now lives in config.providerSourceMode. Local overrides shadow upstream by root precedence and should not rely on provider-level disableUpstream. */
455
457
  disableUpstream?: boolean;
@@ -59,6 +59,7 @@ const KNOWN_PROVIDER_FIELDS = new Set<string>([
59
59
  'sendDelayMs',
60
60
  'sendKey',
61
61
  'submitStrategy',
62
+ 'requirePromptEchoBeforeSubmit',
62
63
  'timeouts',
63
64
  'disableUpstream',
64
65
  ])