@adhdev/daemon-core 1.0.28-rc.10 → 1.0.28-rc.11

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.
@@ -413,6 +413,10 @@ export declare class CliProviderInstance implements ProviderInstance {
413
413
  private completedDebounceTimer;
414
414
  private completedDebouncePending;
415
415
  private lastExternalCompletionProbe;
416
+ /** TX-FSM Stage 0 (shadow): lazily-created transcript signal normalizer.
417
+ * Fed ONLY by transcript reads this instance already performs — it adds
418
+ * zero I/O and its output never feeds back into any verdict. */
419
+ private transcriptSignalSource;
416
420
  /**
417
421
  * The final assistant summary of the last completed turn, cached at
418
422
  * completion-emit time. For a native-source provider (antigravity) whose
@@ -448,6 +452,18 @@ export declare class CliProviderInstance implements ProviderInstance {
448
452
  */
449
453
  private spawnedEnvOverrides;
450
454
  private readExternalCompletionMessages;
455
+ /**
456
+ * TX-FSM Stage 0 (shadow): normalize the transcript read that JUST
457
+ * happened into a SignalSnapshot and inject it into the FSM driver as a
458
+ * pure observation (daemon → SpecCliAdapter → FsmDriver). Fed ONLY by
459
+ * reads this method's caller already performs — it adds zero I/O, so the
460
+ * getState() zero-native-read invariant and the stall-path read cadence
461
+ * are untouched. The snapshot is shadow data: nothing on this path feeds
462
+ * back into completion/stall/redrive verdicts. Fail-open end to end — a
463
+ * missing adapter hook (non-spec providers), an unresolved transcript, or
464
+ * any throw degrades to "no observation", never to a wedge.
465
+ */
466
+ private publishTranscriptSignalObservation;
451
467
  /**
452
468
  * The content of the LAST visible assistant bubble in a message list, or ''
453
469
  * when the tail is not an assistant reply. Skips trailing system/tool/activity
@@ -223,6 +223,13 @@ export declare class SpecCliAdapter implements CliAdapter {
223
223
  getRuntimeMetadata(): unknown;
224
224
  updateRuntimeMeta(meta?: Record<string, unknown>): void;
225
225
  refreshProviderDefinition(): void;
226
+ /**
227
+ * TX-FSM Stage 0 (shadow): forward the daemon's normalized signal
228
+ * observation into the FSM driver. Observation-only — failures here must
229
+ * never break the adapter, and the driver treats the envelope as a pure
230
+ * injected value (no reads cross the engine boundary).
231
+ */
232
+ setSignalObservation(snapshot: import('./signal-envelope.js').SignalSnapshot | null): void;
226
233
  private handleEvent;
227
234
  private detectInteractivePromptFromPtyChunk;
228
235
  private readCurrentScreenSections;
@@ -2,6 +2,7 @@ import { type SpecPtyEvent } from './adapter.js';
2
2
  import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
3
3
  import { type TraceEntry } from './evaluator.js';
4
4
  import { type TransitionEval } from './fsm-evaluator.js';
5
+ import type { SignalSnapshot } from './signal-envelope.js';
5
6
  import { chunkPreservingSurrogates as chunkPreservingSurrogatesShared } from '../../cli-adapters/pty-write-chunking.js';
6
7
  export type DashboardEvent = {
7
8
  kind: 'pty_data';
@@ -159,6 +160,14 @@ export interface ISpecDriver {
159
160
  getFsmDebug?(): unknown;
160
161
  getFsmSnapshotHistory?(): ReadonlyArray<FsmSnapshotEntry>;
161
162
  getEventTimeline?(limit?: number): ReadonlyArray<SpecPtyEvent>;
163
+ /**
164
+ * TX-FSM Stage 0 (shadow): inject the daemon-normalized signal observation.
165
+ * Observation ONLY — the driver receives the envelope, never a reader; all
166
+ * file discovery / session pinning / parsing stays daemon-side so the FSM
167
+ * engine remains a generic PTY engine. The snapshot feeds the shadow
168
+ * verdict of `signal` conditions exclusively; it cannot gate a transition.
169
+ */
170
+ setSignalObservation?(snapshot: SignalSnapshot | null): void;
162
171
  }
163
172
  export interface SpecDriverOpts {
164
173
  specPath: string;
@@ -244,6 +253,15 @@ export declare class FsmDriver implements ISpecDriver {
244
253
  * transition — the rich pre-transition table that lastFsmEval only keeps
245
254
  * for the single most recent evaluation. Separate from stateHistory. */
246
255
  private fsmSnapshotHistory;
256
+ /** TX-FSM Stage 0 (shadow): the latest daemon-injected signal observation.
257
+ * Read by evalFsmNow for the shadow verdict of `signal` conditions ONLY —
258
+ * the Stage-0 pass-through in the evaluator means it can never alter
259
+ * which transition fires. */
260
+ private signalObservation;
261
+ /** Per-transition last-logged shadow divergence (`${from}→${to}` → whether
262
+ * the shadow verdict currently disagrees with the real one), so the
263
+ * shadow log emits on FLIP only, not every frame. */
264
+ private shadowDivergenceLast;
247
265
  constructor(opts: SpecDriverOpts);
248
266
  subscribe(listener: (ev: DashboardEvent) => void): () => void;
249
267
  start(): void;
@@ -259,6 +277,14 @@ export declare class FsmDriver implements ISpecDriver {
259
277
  * the session registry. Not a DashboardCommand — this is a control-plane
260
278
  * update, not user input. */
261
279
  updateMeta(meta: Record<string, unknown>, replace?: boolean): void;
280
+ /**
281
+ * TX-FSM Stage 0 (shadow): receive the daemon-normalized signal
282
+ * observation. The driver stores it verbatim — it does NOT poll, parse,
283
+ * or read anything itself (generic-PTY-engine boundary). The observation
284
+ * only feeds the shadow verdict of `signal` conditions; the evaluator's
285
+ * Stage-0 pass-through guarantees it cannot change a transition.
286
+ */
287
+ setSignalObservation(snapshot: SignalSnapshot | null): void;
262
288
  snapshot(): string;
263
289
  getCursorPosition(): {
264
290
  row: number;
@@ -319,6 +345,15 @@ export declare class FsmDriver implements ISpecDriver {
319
345
  private emitInitialState;
320
346
  private buildClock;
321
347
  private evalFsmNow;
348
+ /**
349
+ * TX-FSM Stage 0 (shadow): compare each signal-guarded transition's
350
+ * counterfactual verdict against the real (PTY-only) one and log on FLIP.
351
+ * This is the "만약 적용했다면" half of the shadow log — the Stage 1-3
352
+ * evidence for whether signal gating would have changed any verdict, and
353
+ * in which direction. Read-only: runs after the evaluation is complete
354
+ * and never feeds back into it.
355
+ */
356
+ private logShadowDivergence;
322
357
  private reevaluate;
323
358
  private commitTransition;
324
359
  /** Snapshot the full FSM evaluation that produced a transition into the
@@ -1,6 +1,7 @@
1
1
  import type { SectionDef } from './types.js';
2
2
  import { type ResolvedSection, type TraceEntry } from './evaluator.js';
3
3
  import { type CliSpecV4, type FsmCondition } from './fsm-types.js';
4
+ import { type SignalSnapshot } from './signal-envelope.js';
4
5
  export interface FsmClock {
5
6
  /** Wall-clock now (ms). Passed in so the evaluator stays pure. */
6
7
  now: number;
@@ -14,7 +15,7 @@ export interface FsmClock {
14
15
  }
15
16
  /** Per-condition evaluation detail — the debugging payload. */
16
17
  export interface CondResult {
17
- kind: 'regex' | 'changed' | 'elapsed' | 'stable' | 'all' | 'any' | 'not';
18
+ kind: 'regex' | 'changed' | 'elapsed' | 'stable' | 'signal' | 'all' | 'any' | 'not';
18
19
  result: boolean;
19
20
  detail: string;
20
21
  /** Remaining ms until a time-based condition would flip to true. 0 if
@@ -24,6 +25,17 @@ export interface CondResult {
24
25
  * snapshot shows WHAT text the rule fired on — not just which regex. Never
25
26
  * read by the FSM; purely for the Spec Debug Snapshot. */
26
27
  matchedText?: string;
28
+ /** TX-FSM Stage 0 (shadow): present only on `signal` leaves. `result` above
29
+ * is ALWAYS true for a signal leaf (Stage-0 pass-through — the leaf never
30
+ * gates a transition); this payload records what the leaf WOULD have
31
+ * decided against the injected snapshot. shadowResult null = the signal
32
+ * was unavailable/unknown (fail-open). */
33
+ signal?: {
34
+ name: string;
35
+ available: boolean;
36
+ value: boolean | null;
37
+ shadowResult: boolean | null;
38
+ };
27
39
  children?: CondResult[];
28
40
  }
29
41
  /** One evaluated transition with its full reasoning. */
@@ -42,6 +54,20 @@ export interface TransitionEval {
42
54
  /** Overall: would this transition fire? */
43
55
  fires: boolean;
44
56
  priority: number;
57
+ /**
58
+ * TX-FSM Stage 0 (shadow): present only when the guard contains at least
59
+ * one `signal` leaf. Records the verdict the transition WOULD have
60
+ * produced if signal leaves gated (condResult/fires), so the shadow log
61
+ * can compare it against the real PTY-only outcome. `unknown` = at least
62
+ * one signal leaf had no observation (fail-open); the shadow verdict is
63
+ * then informational, not a counterfactual proof. NEVER consumed by the
64
+ * driver for any transition decision — log/debug surface only.
65
+ */
66
+ shadow?: {
67
+ condResult: boolean;
68
+ fires: boolean;
69
+ unknown: boolean;
70
+ };
45
71
  }
46
72
  export interface FsmEvaluation {
47
73
  /** Sections resolved from the screen (shared with v3 shape). */
@@ -79,7 +105,7 @@ export declare function stableRegionKey(cond: {
79
105
  export declare function evaluateFsm(spec: CliSpecV4, currentStateId: string, screenText: string, cursor: {
80
106
  row: number;
81
107
  col: number;
82
- } | undefined, prevLines: string[] | undefined, clock: FsmClock): FsmEvaluation;
108
+ } | undefined, prevLines: string[] | undefined, clock: FsmClock, signalSnapshot?: SignalSnapshot | null): FsmEvaluation;
83
109
  /**
84
110
  * Evaluate a single condition against a screen + section map — for the spec
85
111
  * editor's live preview ("does this regex match the current screen right
@@ -49,7 +49,25 @@ export interface FsmAnyCondition {
49
49
  export interface FsmNotCondition {
50
50
  not: FsmCondition;
51
51
  }
52
- export type FsmCondition = RegexCondition | ChangedCondition | ElapsedCondition | StableCondition | FsmAllCondition | FsmAnyCondition | FsmNotCondition;
52
+ /**
53
+ * TX-FSM Stage 0 (shadow) — a daemon-signal leaf. References a NORMALIZED
54
+ * signal name from the SignalSnapshot envelope (never a provider name), so a
55
+ * spec can combine transcript evidence with PTY conditions via all/any/not:
56
+ *
57
+ * { all: [ { matches: "…" }, { signal: "final_assistant_present" } ] }
58
+ *
59
+ * STAGE-0 SEMANTICS: evaluated for the shadow log only. The leaf NEVER gates
60
+ * a transition — the evaluator treats it as pass-through for the real verdict
61
+ * and records what it WOULD have decided (TransitionEval.shadow). A missing
62
+ * or unavailable signal fails open. Promotion to a real gate is Stage 1+.
63
+ */
64
+ export interface FsmSignalCondition {
65
+ /** Normalized signal name (see SIGNAL_NAMES in signal-envelope.ts). */
66
+ signal: string;
67
+ /** Expected value; default true. */
68
+ equals?: boolean;
69
+ }
70
+ export type FsmCondition = RegexCondition | ChangedCondition | ElapsedCondition | StableCondition | FsmSignalCondition | FsmAllCondition | FsmAnyCondition | FsmNotCondition;
53
71
  export interface FsmState {
54
72
  id: string;
55
73
  label: string;
@@ -0,0 +1,59 @@
1
+ import type { TranscriptClass, CompletionTiming } from '../transcript-evidence.js';
2
+ export declare const SIGNAL_SNAPSHOT_KIND: "adhdev:fsm/signal-snapshot@0";
3
+ /** The normalized signal vocabulary. Stage 0 keeps this deliberately small —
4
+ * every name must be derivable from the FLATTENED native-transcript pipeline
5
+ * (which drops tool_use/tool_result correlation ids — that raw-JSONL read is
6
+ * Stage 4 territory and must NOT be added here). */
7
+ export type SignalName = 'final_assistant_present' | 'in_turn_progress' | 'transcript_growing';
8
+ export declare const SIGNAL_NAMES: readonly SignalName[];
9
+ export type SignalUnavailableReason = 'no_native_source' | 'unresolved' | 'error';
10
+ /** The normalized, provider-agnostic observation the daemon hands the FSM. */
11
+ export interface SignalSnapshot {
12
+ kind: typeof SIGNAL_SNAPSHOT_KIND;
13
+ /** Wall-clock time the underlying transcript read happened (ms). */
14
+ sampledAt: number;
15
+ /** False ⇒ every signal is null and conditions fail open. */
16
+ available: boolean;
17
+ unavailableReason?: SignalUnavailableReason;
18
+ /** The authority profile class/timing this snapshot was produced under
19
+ * (via resolveTranscriptAuthorityProfile — never raw predicates). */
20
+ profile?: {
21
+ class: TranscriptClass;
22
+ timing: CompletionTiming;
23
+ };
24
+ /** Normalized signals; null = unknown (fail-open). */
25
+ signals: Record<SignalName, boolean | null>;
26
+ /** Raw sampling context for the shadow log / Stage 1-3 analysis. */
27
+ detail: {
28
+ msgCount: number;
29
+ sourceMtimeMs: number;
30
+ /** now - sourceMtimeMs; null when the mtime is unknown (0). */
31
+ ageMs: number | null;
32
+ };
33
+ }
34
+ /** The fail-open snapshot: no usable observation, every signal null. */
35
+ export declare function unavailableSignalSnapshot(now: number, reason: SignalUnavailableReason, profile?: {
36
+ class: TranscriptClass;
37
+ timing: CompletionTiming;
38
+ }): SignalSnapshot;
39
+ /** The spec's `signal` leaf: `{ signal: 'transcript_growing', equals?: boolean }`. */
40
+ export interface SignalLeaf {
41
+ signal: string;
42
+ equals?: boolean;
43
+ }
44
+ export interface SignalLeafEval {
45
+ /** The normalized signal value read from the snapshot; null = unknown. */
46
+ value: boolean | null;
47
+ /** What the leaf WOULD evaluate to if signals gated transitions; null =
48
+ * unknown (no snapshot / signal missing → fail-open). */
49
+ shadowResult: boolean | null;
50
+ /** Human-readable shadow-log fragment. */
51
+ detail: string;
52
+ }
53
+ /**
54
+ * Evaluate one `signal` leaf against the injected snapshot. This computes the
55
+ * SHADOW verdict only — the caller (fsm-evaluator) owns the Stage-0
56
+ * pass-through rule that keeps the real result true regardless of what this
57
+ * returns.
58
+ */
59
+ export declare function evaluateSignalLeaf(cond: SignalLeaf, snapshot: SignalSnapshot | null | undefined): SignalLeafEval;
@@ -0,0 +1,59 @@
1
+ import type { TranscriptAuthorityProfile } from './transcript-evidence.js';
2
+ import { type SignalSnapshot } from './spec/signal-envelope.js';
3
+ /** One daemon transcript read, normalized for the source. */
4
+ export interface TranscriptSignalSample {
5
+ /** Parsed native-transcript messages, or null when the read failed to
6
+ * resolve a source this time. */
7
+ messages: unknown[] | null;
8
+ /** The probe metadata the read refreshed (sourcePath/mtime/msgCount), or
9
+ * null when no source is bound. A null sourceMtimeMs is treated as "no
10
+ * freshness evidence" (same contract as the growth-hold path). */
11
+ probe: {
12
+ msgCount: number;
13
+ sourceMtimeMs: number | null;
14
+ sourcePath?: string | null;
15
+ } | null;
16
+ /** True when the read threw — logged distinctly from a clean miss. */
17
+ error?: boolean;
18
+ }
19
+ export interface TranscriptSignalSourceOpts {
20
+ /** Log tag (provider type / session label) — never used for branching. */
21
+ label: string;
22
+ /** The P0 choke-point profile for this provider. */
23
+ profile: TranscriptAuthorityProfile;
24
+ /** Turn-boundary clock (adapter.currentTurnStartedAt) for turn-scoped
25
+ * final-assistant detection; may yield undefined pre-turn. */
26
+ turnStartedAt?: () => number | undefined;
27
+ /** In-turn final-assistant predicate over the flattened messages — the
28
+ * instance passes its existing completionHasFinalAssistantMessage so the
29
+ * signal reuses the exact completion machinery instead of duplicating it. */
30
+ finalAssistantPresent: (messages: unknown[], turnStartedAt?: number) => boolean;
31
+ /** Freshness window (ms) for transcript_growing. The caller passes the
32
+ * completion pipeline's MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS so
33
+ * the shadow signal is comparable to the existing growth-hold judgment. */
34
+ growthQuietMs: number;
35
+ }
36
+ export declare class TranscriptSignalSource {
37
+ private readonly opts;
38
+ /** msgCount seen at the previous update — drives in_turn_progress. */
39
+ private prevMsgCount;
40
+ /** Fingerprint of the last LOGGED snapshot, so the shadow log fires on
41
+ * change only (a quiet session must not spam one line per read). */
42
+ private lastLoggedFingerprint;
43
+ constructor(opts: TranscriptSignalSourceOpts);
44
+ /**
45
+ * Normalize one daemon read into a SignalSnapshot and emit the Stage-0
46
+ * shadow log when the observation changed. Pure w.r.t. the outside world:
47
+ * the only side effect is the (change-gated) log line. Returns the
48
+ * snapshot the caller should inject into the FSM driver.
49
+ */
50
+ update(sample: TranscriptSignalSample, now?: number): SignalSnapshot;
51
+ /** Pure normalization — separated from update() so tests can assert the
52
+ * envelope without touching the log path. */
53
+ buildSnapshot(sample: TranscriptSignalSample, now: number): SignalSnapshot;
54
+ /** Stage-0 shadow log: emit one line when the normalized observation
55
+ * CHANGES. This log is the Stage 1-3 judgment input ("which signals were
56
+ * actually observable, and when"), so it carries the full signal set —
57
+ * but only on change, so a steady-state session stays quiet. */
58
+ private logOnChange;
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.28-rc.10",
3
+ "version": "1.0.28-rc.11",
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",
@@ -49,8 +49,8 @@
49
49
  "author": "vilmire",
50
50
  "license": "AGPL-3.0-or-later",
51
51
  "dependencies": {
52
- "@adhdev/mesh-shared": "1.0.28-rc.10",
53
- "@adhdev/session-host-core": "1.0.28-rc.10",
52
+ "@adhdev/mesh-shared": "1.0.28-rc.11",
53
+ "@adhdev/session-host-core": "1.0.28-rc.11",
54
54
  "@agentclientprotocol/sdk": "^0.16.1",
55
55
  "ajv": "^8.20.0",
56
56
  "ajv-formats": "^3.0.1",
@@ -173,6 +173,13 @@ export interface CliAdapter {
173
173
  // back to resolveModal + the resolution-cooldown check).
174
174
  resolveModalMatched?(buttonIndex: number): boolean;
175
175
  isApprovalRecentlyResolved?(): boolean;
176
+ /**
177
+ * TX-FSM Stage 0 (shadow): receive the daemon-normalized transcript signal
178
+ * observation (SignalSnapshot envelope, providers/spec/signal-envelope.ts).
179
+ * Optional — only the spec-driven FSM adapter implements it; callers must
180
+ * typeof-guard. Shadow-only: the observation can never gate a transition.
181
+ */
182
+ setSignalObservation?(snapshot: unknown): void;
176
183
  // Raw PTY I/O (for terminal view)
177
184
  setOnPtyData?(callback: (data: string) => void): void;
178
185
  writeRaw?(data: string): void;
@@ -112,6 +112,47 @@ export const daemonLifecycleHandlers: Record<string, LowFamilyHandler> = {
112
112
  }
113
113
  },
114
114
 
115
+ // Restart-only path (mesh restart_daemon_node mode="restart"): re-spawn the
116
+ // daemon WITHOUT the npm reinstall daemon_upgrade performs. Used to reset
117
+ // daemon state (memory leaks, zombie sessions, wedged internals) when the
118
+ // version is already correct — downtime drops to the detached re-spawn.
119
+ // killSessionHost is an explicit opt-in hard refresh (see upgrade-helper).
120
+ daemon_restart: async (ctx: LowFamilyContext, args: any) => {
121
+ LOG.info('Restart', 'Restart-only requested (no package reinstall)');
122
+ try {
123
+ const isStandalone = ctx.deps.packageName === '@adhdev/daemon-standalone'
124
+ || process.argv[1]?.includes('daemon-standalone');
125
+ const pkgName = isStandalone ? '@adhdev/daemon-standalone' : 'adhdev';
126
+ const killSessionHost = args?.killSessionHost === true;
127
+ if (killSessionHost) {
128
+ LOG.warn('Restart', 'killSessionHost requested — session-host will be stopped and ALL hosted sessions destroyed');
129
+ }
130
+
131
+ spawnDetachedDaemonUpgradeHelper({
132
+ packageName: pkgName,
133
+ targetVersion: '',
134
+ parentPid: process.pid,
135
+ restartArgv: process.argv.slice(1),
136
+ cwd: process.cwd(),
137
+ sessionHostAppName: process.env.ADHDEV_SESSION_HOST_NAME || 'adhdev',
138
+ skipInstall: true,
139
+ killSessionHost,
140
+ });
141
+ LOG.info('Restart', 'Scheduled detached restart-only helper');
142
+
143
+ // Exit after the command response has been sent so the helper can re-spawn cleanly.
144
+ setTimeout(() => {
145
+ LOG.info('Restart', 'Exiting daemon so detached helper can re-spawn it...');
146
+ process.exit(0);
147
+ }, 3000);
148
+
149
+ return { success: true, restarted: true, restarting: true, mode: 'restart', killSessionHost };
150
+ } catch (e: any) {
151
+ LOG.error('Restart', `Failed: ${e.message}`);
152
+ return { success: false, error: e.message };
153
+ }
154
+ },
155
+
115
156
  set_machine_nickname: async (_ctx: LowFamilyContext, args: any) => {
116
157
  const nickname = args?.nickname;
117
158
  updateConfig({ machineNickname: nickname || null });