@adhdev/daemon-core 0.9.82-rc.166 → 0.9.82-rc.168

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.
@@ -31,6 +31,7 @@ export interface ModalTuiSpec {
31
31
  questionVariants?: ModalQuestionVariant[];
32
32
  buttonPattern: string;
33
33
  buttonFlags?: string;
34
+ buttonLabelGroup?: number;
34
35
  /**
35
36
  * Optional fallback for terminals that render all options on a single line
36
37
  * (e.g. Antigravity feedback survey: `[0] skip [1] yes [2] no [3] still using`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.166",
3
+ "version": "0.9.82-rc.168",
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",
@@ -0,0 +1,252 @@
1
+ import { randomUUID } from 'crypto';
2
+ import {
3
+ SessionHostClient,
4
+ type SessionHostEndpoint,
5
+ type SessionHostRequest,
6
+ type SessionHostResponse,
7
+ type SessionTerminalSnapshot,
8
+ type SessionTerminalState,
9
+ } from '@adhdev/session-host-core';
10
+
11
+ type LowercaseLetter =
12
+ | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm'
13
+ | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z';
14
+
15
+ type FunctionKey = 'f1' | 'f2' | 'f3' | 'f4' | 'f5' | 'f6' | 'f7' | 'f8' | 'f9' | 'f10' | 'f11' | 'f12';
16
+ type BaseNamedKey =
17
+ | 'enter' | 'escape' | 'tab' | 'backspace'
18
+ | 'up' | 'down' | 'left' | 'right'
19
+ | 'home' | 'end' | 'pageup' | 'pagedown' | 'space'
20
+ | FunctionKey;
21
+ type ShiftNamedKey = BaseNamedKey | LowercaseLetter | `ctrl+${LowercaseLetter}` | `alt+${LowercaseLetter}`;
22
+
23
+ export type NamedKey =
24
+ | BaseNamedKey
25
+ | `ctrl+${LowercaseLetter}`
26
+ | `alt+${LowercaseLetter}`
27
+ | `shift+${ShiftNamedKey}`;
28
+
29
+ const BASE_KEY_SEQUENCES: Record<BaseNamedKey, string> = {
30
+ enter: '\r',
31
+ escape: '\x1b',
32
+ tab: '\t',
33
+ backspace: '\x7f',
34
+ up: '\x1b[A',
35
+ down: '\x1b[B',
36
+ right: '\x1b[C',
37
+ left: '\x1b[D',
38
+ home: '\x1b[H',
39
+ end: '\x1b[F',
40
+ pageup: '\x1b[5~',
41
+ pagedown: '\x1b[6~',
42
+ space: ' ',
43
+ f1: '\x1bOP',
44
+ f2: '\x1bOQ',
45
+ f3: '\x1bOR',
46
+ f4: '\x1bOS',
47
+ f5: '\x1b[15~',
48
+ f6: '\x1b[17~',
49
+ f7: '\x1b[18~',
50
+ f8: '\x1b[19~',
51
+ f9: '\x1b[20~',
52
+ f10: '\x1b[21~',
53
+ f11: '\x1b[23~',
54
+ f12: '\x1b[24~',
55
+ };
56
+
57
+ const SHIFTED_CSI_KEYS: Partial<Record<BaseNamedKey, string>> = {
58
+ up: '\x1b[1;2A',
59
+ down: '\x1b[1;2B',
60
+ right: '\x1b[1;2C',
61
+ left: '\x1b[1;2D',
62
+ home: '\x1b[1;2H',
63
+ end: '\x1b[1;2F',
64
+ pageup: '\x1b[5;2~',
65
+ pagedown: '\x1b[6;2~',
66
+ f1: '\x1b[1;2P',
67
+ f2: '\x1b[1;2Q',
68
+ f3: '\x1b[1;2R',
69
+ f4: '\x1b[1;2S',
70
+ f5: '\x1b[15;2~',
71
+ f6: '\x1b[17;2~',
72
+ f7: '\x1b[18;2~',
73
+ f8: '\x1b[19;2~',
74
+ f9: '\x1b[20;2~',
75
+ f10: '\x1b[21;2~',
76
+ f11: '\x1b[23;2~',
77
+ f12: '\x1b[24;2~',
78
+ };
79
+
80
+ function isLowercaseLetter(value: string): value is LowercaseLetter {
81
+ return /^[a-z]$/.test(value);
82
+ }
83
+
84
+ function encodeControlLetter(letter: LowercaseLetter): string {
85
+ return String.fromCharCode(letter.charCodeAt(0) - 96);
86
+ }
87
+
88
+ function encodeShiftedKey(key: string): string {
89
+ if (isLowercaseLetter(key)) return key.toUpperCase();
90
+ if (key.startsWith('ctrl+') && isLowercaseLetter(key.slice(5))) {
91
+ return encodeControlLetter(key.slice(5) as LowercaseLetter);
92
+ }
93
+ if (key.startsWith('alt+') && isLowercaseLetter(key.slice(4))) {
94
+ return `\x1b${key.slice(4).toUpperCase()}`;
95
+ }
96
+ if (key === 'tab') return '\x1b[Z';
97
+ if (key in SHIFTED_CSI_KEYS) return SHIFTED_CSI_KEYS[key as BaseNamedKey]!;
98
+ if (key in BASE_KEY_SEQUENCES) return BASE_KEY_SEQUENCES[key as BaseNamedKey];
99
+ throw new Error(`Unsupported named key: shift+${key}`);
100
+ }
101
+
102
+ export function namedKeyToAnsi(key: NamedKey | string): string {
103
+ const normalized = String(key || '').trim().toLowerCase();
104
+ if (normalized in BASE_KEY_SEQUENCES) return BASE_KEY_SEQUENCES[normalized as BaseNamedKey];
105
+ if (normalized.startsWith('ctrl+') && isLowercaseLetter(normalized.slice(5))) {
106
+ return encodeControlLetter(normalized.slice(5) as LowercaseLetter);
107
+ }
108
+ if (normalized.startsWith('alt+') && isLowercaseLetter(normalized.slice(4))) {
109
+ return `\x1b${normalized.slice(4)}`;
110
+ }
111
+ if (normalized.startsWith('shift+')) return encodeShiftedKey(normalized.slice(6));
112
+ throw new Error(`Unsupported named key: ${key}`);
113
+ }
114
+
115
+ export function namedKeysToAnsi(keys: readonly (NamedKey | string)[]): string {
116
+ if (!Array.isArray(keys)) throw new Error('keys must be an array');
117
+ return keys.map(namedKeyToAnsi).join('');
118
+ }
119
+
120
+ export interface RawTerminalSessionHostClient {
121
+ connect(): Promise<void>;
122
+ request<T = unknown>(request: SessionHostRequest): Promise<SessionHostResponse<T>>;
123
+ close(): Promise<void>;
124
+ }
125
+
126
+ export interface RawTerminalAttachmentOptions {
127
+ endpoint?: SessionHostEndpoint;
128
+ sessionId: string;
129
+ mode?: 'read' | 'write';
130
+ clientId?: string;
131
+ client?: RawTerminalSessionHostClient;
132
+ }
133
+
134
+ export class RawTerminalAttachment {
135
+ private closed = false;
136
+
137
+ private constructor(
138
+ readonly sessionId: string,
139
+ private readonly clientId: string,
140
+ private readonly mode: 'read' | 'write',
141
+ private readonly client: RawTerminalSessionHostClient,
142
+ ) {}
143
+
144
+ static async attach(options: RawTerminalAttachmentOptions): Promise<RawTerminalAttachment> {
145
+ const sessionId = String(options.sessionId || '').trim();
146
+ if (!sessionId) throw new Error('sessionId is required');
147
+ const mode = options.mode || 'read';
148
+ const clientId = options.clientId || `raw-terminal-${process.pid}-${randomUUID().slice(0, 8)}`;
149
+ const client = options.client || new SessionHostClient({ endpoint: options.endpoint });
150
+ await client.connect();
151
+
152
+ const attachResponse = await client.request({
153
+ type: 'attach_session',
154
+ payload: {
155
+ sessionId,
156
+ clientId,
157
+ clientType: 'web',
158
+ readOnly: mode === 'read',
159
+ },
160
+ });
161
+ if (!attachResponse.success) {
162
+ await client.close().catch(() => {});
163
+ throw new Error(attachResponse.error || `Failed to attach terminal session ${sessionId}`);
164
+ }
165
+
166
+ if (mode === 'write') {
167
+ const ownerResponse = await client.request({
168
+ type: 'acquire_write',
169
+ payload: {
170
+ sessionId,
171
+ clientId,
172
+ ownerType: 'user',
173
+ force: true,
174
+ },
175
+ });
176
+ if (!ownerResponse.success) {
177
+ await client.request({
178
+ type: 'detach_session',
179
+ payload: { sessionId, clientId },
180
+ }).catch(() => ({ success: false }));
181
+ await client.close().catch(() => {});
182
+ throw new Error(ownerResponse.error || `Failed to acquire terminal session ${sessionId}`);
183
+ }
184
+ }
185
+
186
+ return new RawTerminalAttachment(sessionId, clientId, mode, client);
187
+ }
188
+
189
+ async readSnapshot(): Promise<SessionTerminalSnapshot> {
190
+ const response = await this.client.request<SessionTerminalSnapshot>({
191
+ type: 'get_terminal_snapshot',
192
+ payload: { sessionId: this.sessionId },
193
+ });
194
+ if (!response.success || !response.result) {
195
+ throw new Error(response.error || `Terminal screen unavailable for ${this.sessionId}`);
196
+ }
197
+ return response.result;
198
+ }
199
+
200
+ async readScreenText(): Promise<string> {
201
+ return (await this.readSnapshot()).text;
202
+ }
203
+
204
+ async readState(): Promise<SessionTerminalState> {
205
+ return (await this.readSnapshot()).state;
206
+ }
207
+
208
+ async writeInput(text: string): Promise<void> {
209
+ if (this.mode !== 'write') throw new Error('Raw terminal attachment is read-only');
210
+ const response = await this.client.request({
211
+ type: 'send_input',
212
+ payload: {
213
+ sessionId: this.sessionId,
214
+ clientId: this.clientId,
215
+ data: text,
216
+ },
217
+ });
218
+ if (!response.success) throw new Error(response.error || `Failed to write terminal input to ${this.sessionId}`);
219
+ }
220
+
221
+ async writeKeys(keys: readonly (NamedKey | string)[]): Promise<void> {
222
+ await this.writeInput(namedKeysToAnsi(keys));
223
+ }
224
+
225
+ async close(): Promise<void> {
226
+ if (this.closed) return;
227
+ this.closed = true;
228
+ if (this.mode === 'write') {
229
+ await this.client.request({
230
+ type: 'release_write',
231
+ payload: { sessionId: this.sessionId, clientId: this.clientId },
232
+ }).catch(() => ({ success: false }));
233
+ }
234
+ await this.client.request({
235
+ type: 'detach_session',
236
+ payload: { sessionId: this.sessionId, clientId: this.clientId },
237
+ }).catch(() => ({ success: false }));
238
+ await this.client.close().catch(() => {});
239
+ }
240
+ }
241
+
242
+ export async function withRawTerminalAttachment<T>(
243
+ options: RawTerminalAttachmentOptions,
244
+ operation: (attachment: RawTerminalAttachment) => Promise<T>,
245
+ ): Promise<T> {
246
+ const attachment = await RawTerminalAttachment.attach(options);
247
+ try {
248
+ return await operation(attachment);
249
+ } finally {
250
+ await attachment.close();
251
+ }
252
+ }
package/src/index.ts CHANGED
@@ -428,6 +428,13 @@ export type { CliAdapter } from './cli-adapter-types.js';
428
428
  export { NodePtyTransportFactory } from './cli-adapters/pty-transport.js';
429
429
  export type { PtyRuntimeTransport, PtyTransportFactory, PtySpawnOptions } from './cli-adapters/pty-transport.js';
430
430
  export { SessionHostPtyTransportFactory } from './cli-adapters/session-host-transport.js';
431
+ export {
432
+ RawTerminalAttachment,
433
+ namedKeyToAnsi,
434
+ namedKeysToAnsi,
435
+ withRawTerminalAttachment,
436
+ } from './cli-adapters/raw-terminal-io.js';
437
+ export type { NamedKey, RawTerminalAttachmentOptions, RawTerminalSessionHostClient } from './cli-adapters/raw-terminal-io.js';
431
438
  export type { HostedCliRuntimeDescriptor, CliTransportFactoryParams } from './commands/cli-manager.js';
432
439
  export {
433
440
  DEFAULT_SESSION_HOST_APP_NAME,
@@ -556,6 +556,24 @@ function hasDispatchAfterTerminal(meshId: string, sessionId: string, terminalId:
556
556
  return false;
557
557
  }
558
558
 
559
+ function hasUnterminalDirectDispatchLedgerEntry(meshId: string, sessionId: string): boolean {
560
+ // Some dispatch paths can persist task_dispatched before the direct-dispatch DB row is
561
+ // available. Recover routing from ledger order so coordinator self-targets still emit
562
+ // task_completed and pendingCoordinatorEvents.
563
+ const entries = readLedgerEntries(meshId, { tail: 200 });
564
+ for (let i = entries.length - 1; i >= 0; i--) {
565
+ const entry = entries[i];
566
+ if (entry.sessionId !== sessionId) continue;
567
+ if (entry.kind === 'task_completed' || entry.kind === 'task_failed' || entry.kind === 'task_stalled') {
568
+ return false;
569
+ }
570
+ if (entry.kind === 'task_dispatched' && entry.payload?.source === 'direct') {
571
+ return true;
572
+ }
573
+ }
574
+ return false;
575
+ }
576
+
559
577
  function buildLongGeneratingCompletionReconciliation(args: {
560
578
  meshId: string;
561
579
  nodeId?: string;
@@ -1627,7 +1645,7 @@ export function setupMeshEventForwarding(components: DaemonComponents) {
1627
1645
  if (coordinatorMeshId) {
1628
1646
  try {
1629
1647
  const activeDispatches = getActiveDirectDispatches(coordinatorMeshId);
1630
- if (activeDispatches.some(d => d.sessionId === instanceId)) {
1648
+ if (activeDispatches.some(d => d.sessionId === instanceId) || hasUnterminalDirectDispatchLedgerEntry(coordinatorMeshId, instanceId)) {
1631
1649
  meshIdFromDirectDispatch = coordinatorMeshId;
1632
1650
  }
1633
1651
  } catch { /* best-effort */ }
@@ -729,7 +729,7 @@ export class CliProviderInstance implements ProviderInstance {
729
729
  }
730
730
 
731
731
  getSessionModalState(sessionId?: string): SessionModalState {
732
- const adapterStatus = this.adapter.getStatus({ allowParse: false });
732
+ const adapterStatus = this.adapter.getStatus({ allowParse: true });
733
733
  const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
734
734
  const visibleStatus = autoApproveActive ? 'generating' : adapterStatus.status;
735
735
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
@@ -30,6 +30,10 @@ import type {
30
30
  ResolvedProvider,
31
31
  } from './contracts.js';
32
32
  import { validateProviderDefinition } from './provider-schema.js';
33
+ import {
34
+ loadProvidersActive,
35
+ resolveActiveSource,
36
+ } from './external-sources.js';
33
37
  import type { ProviderSourceMode } from '../config/config.js';
34
38
  import type { ProviderSourceConfigSnapshot, ProviderUserDirSource } from '../config/provider-source-config.js';
35
39
 
@@ -430,10 +434,6 @@ export class ProviderLoader {
430
434
  }
431
435
  } else {
432
436
  // New layout: external/<source-name>/<category>/<type>/…
433
- const {
434
- loadProvidersActive,
435
- resolveActiveSource,
436
- } = require('./external-sources.js') as typeof import('./external-sources.js');
437
437
  const activeFile = loadProvidersActive();
438
438
  let totalLoaded = 0;
439
439
  const ambiguousTypes: { type: string; chosen: string; candidates: string[] }[] = [];
@@ -44,6 +44,7 @@ export interface ModalTuiSpec {
44
44
  questionVariants?: ModalQuestionVariant[];
45
45
  buttonPattern: string;
46
46
  buttonFlags?: string;
47
+ buttonLabelGroup?: number;
47
48
  /**
48
49
  * Optional fallback for terminals that render all options on a single line
49
50
  * (e.g. Antigravity feedback survey: `[0] skip [1] yes [2] no [3] still using`).
@@ -136,12 +137,16 @@ function extractButtons(
136
137
  ): string[] {
137
138
  const buttonRe = compile(spec.buttonPattern, spec.buttonFlags ?? 'm');
138
139
  const out: string[] = [];
140
+ const labelGroup = Number.isInteger(spec.buttonLabelGroup) && (spec.buttonLabelGroup ?? 0) > 0
141
+ ? spec.buttonLabelGroup!
142
+ : 1;
139
143
  let i = windowStart;
140
144
  while (i < windowEnd) {
141
145
  const line = lines[i];
142
146
  const m = buttonRe.exec(line);
143
- if (m && m[1]) {
144
- let label = m[1].trim();
147
+ const captured = m?.[labelGroup] ?? (labelGroup === 1 && m && m.length > 2 ? m[m.length - 1] : undefined);
148
+ if (m && captured) {
149
+ let label = captured.trim();
145
150
  // Continuation lines: when enabled, append indented lines below until
146
151
  // the next button or blank.
147
152
  if (spec.continuationLines) {
@@ -36,6 +36,12 @@
36
36
  "type": "string",
37
37
  "description": "Regex source matching a single button line. Capture group 1 is the button label. Example: `^[\\s❯>]*\\d+\\.\\s+(.+)$`."
38
38
  },
39
+ "buttonLabelGroup": {
40
+ "type": "integer",
41
+ "minimum": 1,
42
+ "default": 1,
43
+ "description": "Capture group number to use as the button label when `buttonPattern` contains non-label groups such as selected-row markers."
44
+ },
39
45
  "buttonFlags": {
40
46
  "type": "string",
41
47
  "pattern": "^[gimsuy]*$",