@aion0/forge 0.5.42 → 0.5.43

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.
package/RELEASE_NOTES.md CHANGED
@@ -1,8 +1,11 @@
1
- # Forge v0.5.42
1
+ # Forge v0.5.43
2
2
 
3
- Released: 2026-04-23
3
+ Released: 2026-04-25
4
4
 
5
- ## Changes since v0.5.41
5
+ ## Changes since v0.5.42
6
6
 
7
+ ### Features
8
+ - feat: smith pause/resume + agent_status watch event-driven + primary session picker
7
9
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.41...v0.5.42
10
+
11
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.42...v0.5.43
@@ -148,6 +148,11 @@ export default function Dashboard({ user }: { user: any }) {
148
148
  }, []);
149
149
  useEffect(() => { refreshDisplayName(); }, [refreshDisplayName]);
150
150
 
151
+ // Reflect the current user's name in the browser tab title
152
+ useEffect(() => {
153
+ document.title = displayName && displayName !== 'Forge' ? `Forge — ${displayName}` : 'Forge';
154
+ }, [displayName]);
155
+
151
156
  // Listen for open-terminal events from ProjectManager
152
157
  useEffect(() => {
153
158
  const handler = (e: Event) => {
@@ -278,7 +283,9 @@ export default function Dashboard({ user }: { user: any }) {
278
283
  <header className="h-12 border-b-2 border-[var(--border)] flex items-center justify-between px-4 shrink-0 bg-[var(--bg-secondary)]">
279
284
  <div className="flex items-center gap-4">
280
285
  <img src="/icon.png" alt="Forge" width={28} height={28} className="rounded" />
281
- <span className="text-sm font-bold text-[var(--accent)]">Forge</span>
286
+ <span className="text-sm font-bold text-[var(--accent)]">
287
+ Forge{displayName && displayName !== 'Forge' ? ` · ${displayName}` : ''}
288
+ </span>
282
289
  {versionInfo && (
283
290
  <span className="flex items-center gap-1.5">
284
291
  <span className="text-[10px] text-[var(--text-secondary)]">v{versionInfo.current}</span>
@@ -34,6 +34,7 @@ interface AgentConfig {
34
34
  interface AgentState {
35
35
  smithStatus: 'down' | 'starting' | 'active';
36
36
  taskStatus: 'idle' | 'running' | 'done' | 'failed';
37
+ paused?: boolean;
37
38
  currentStep?: number;
38
39
  tmuxSession?: string;
39
40
  artifacts: { type: string; path?: string; summary?: string }[];
@@ -2794,6 +2795,7 @@ interface AgentNodeData {
2794
2795
  workspaceId: string | null;
2795
2796
  onRun: () => void;
2796
2797
  onPause: () => void;
2798
+ onResume: () => void;
2797
2799
  onStop: () => void;
2798
2800
  onRetry: () => void;
2799
2801
  onEdit: () => void;
@@ -3307,7 +3309,7 @@ function WorkerMascot({ taskStatus, smithStatus, seed, accentColor, theme }: { t
3307
3309
  }
3308
3310
 
3309
3311
  function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
3310
- const { config, state, colorIdx, previewLines, projectPath, workspaceId, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, onSaveAsTemplate, mascotTheme, bellOn = false, onToggleBell, inboxPending = 0, inboxFailed = 0 } = data;
3312
+ const { config, state, colorIdx, previewLines, projectPath, workspaceId, onRun, onPause, onResume, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, onSaveAsTemplate, mascotTheme, bellOn = false, onToggleBell, inboxPending = 0, inboxFailed = 0 } = data;
3311
3313
  const c = COLORS[colorIdx % COLORS.length];
3312
3314
  const smithStatus = state?.smithStatus || 'down';
3313
3315
  const taskStatus = state?.taskStatus || 'idle';
@@ -3426,10 +3428,20 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
3426
3428
  </>
3427
3429
  )}
3428
3430
  {/* Message button — send instructions to agent */}
3429
- {smithStatus === 'active' && taskStatus !== 'running' && (
3431
+ {smithStatus === 'active' && taskStatus !== 'running' && !state?.paused && (
3430
3432
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onMessage(); }}
3431
3433
  className="text-[9px] px-1.5 py-0.5 rounded bg-blue-600/20 text-blue-400 hover:bg-blue-600/30">💬 Message</button>
3432
3434
  )}
3435
+ {/* Pause / Resume — icon-only so it doesn't widen the card */}
3436
+ {smithStatus !== 'down' && config.type !== 'input' && (
3437
+ <button onPointerDown={e => e.stopPropagation()}
3438
+ onClick={e => { e.stopPropagation(); state?.paused ? onResume() : onPause(); }}
3439
+ className={`text-[9px] px-1 ${state?.paused ? 'text-orange-400 hover:text-orange-300' : 'text-gray-600 hover:text-orange-400'}`}
3440
+ title={state?.paused
3441
+ ? 'Paused — click to resume bus pickups and watch alerts'
3442
+ : 'Pause — drop new bus messages and watch alerts as failed (in-flight task continues)'}
3443
+ >{state?.paused ? '▶' : '⏸'}</button>
3444
+ )}
3433
3445
  <div className="flex-1" />
3434
3446
  <span className="flex items-center">
3435
3447
  <button onPointerDown={e => e.stopPropagation()}
@@ -3733,6 +3745,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
3733
3745
  wsApi(workspaceId!, 'run', { agentId: agent.id });
3734
3746
  },
3735
3747
  onPause: () => wsApi(workspaceId!, 'pause', { agentId: agent.id }),
3748
+ onResume: () => wsApi(workspaceId!, 'resume', { agentId: agent.id }),
3736
3749
  onStop: () => wsApi(workspaceId!, 'stop', { agentId: agent.id }),
3737
3750
  mascotTheme,
3738
3751
  bellOn: bellAgents.has(agent.id),
@@ -414,7 +414,7 @@ Each smith can display an animated companion character next to its node.
414
414
  | **Stop Daemon** | Stop all smiths, kill workers. Preserves user's terminal conversation context (no `/clear` is sent). Tmux sessions attached to by a user are kept alive. |
415
415
  | **Run All** | Trigger all runnable agents once |
416
416
  | **Run** | Trigger specific agent |
417
- | **Pause/Resume** | Pause/resume message consumption for one agent |
417
+ | **Pause/Resume** | Pause stops new bus pickups, drops queued + incoming messages to `failed`, and suppresses watch-alert dispatch. In-flight task continues. Resume re-enables. The pause flag is transient — daemon stop/start or process restart clears it. UI: ⏸ / ▶ icon on the smith card. |
418
418
  | **Mark Done/Failed/Idle** | Manually set task status |
419
419
  | **Retry** | Re-run a failed agent from checkpoint |
420
420
  | **Open Terminal** | Enter manual mode with tmux session |
@@ -592,6 +592,7 @@ Use this exact JSON structure when calling `POST /api/workspace/<id>/agents` wit
592
592
 
593
593
  - `action` values: `log` | `analyze` | `approve` | `send_message`
594
594
  - `sendTo` is required only when `action: "send_message"`
595
+ - `agent_status` target is event-driven — orchestrator stamps a transition timestamp on every real `taskStatus` change (idempotent re-emits are deduped). The watch tick compares timestamps and only fires when the target has settled to a non-busy state (not `running`/`starting`) and matches `pattern`. Does not depend on polling, so sub-tick task transitions are caught reliably. Pattern matches the **final** state only — intermediate states during the interval window don't affect matching.
595
596
 
596
597
  ## Complete Recipes
597
598
 
@@ -39,7 +39,7 @@ import {
39
39
  loadMemory, saveMemory, createMemory, formatMemoryForPrompt,
40
40
  addObservation, addSessionSummary, parseStepToObservations, buildSessionSummary,
41
41
  } from './smith-memory';
42
- import { getFixedSession } from '../project-sessions';
42
+ import { getFixedSession, setFixedSession } from '../project-sessions';
43
43
 
44
44
  // ─── Workspace Topology Cache ────────────────────────────
45
45
 
@@ -116,12 +116,29 @@ export class WorkspaceOrchestrator extends EventEmitter {
116
116
  if (event.type === 'log' && event.agentId && event.entry) {
117
117
  appendAgentLog(this.workspaceId, event.agentId, event.entry).catch(() => {});
118
118
  }
119
+ // Stamp taskStatus transitions. Idempotent re-emits of the same value
120
+ // don't bump the timestamp — agent_status watches rely on this to detect
121
+ // real transitions without polling.
122
+ if (event.type === 'task_status' && event.agentId && event.taskStatus) {
123
+ const entry = this.agents.get(event.agentId);
124
+ if (entry && entry.state.lastTaskStatus !== event.taskStatus) {
125
+ const prev = entry.state.lastTaskStatus;
126
+ entry.state.lastTaskStatus = event.taskStatus;
127
+ entry.state.taskStatusChangedAt = Date.now();
128
+ console.log(`[task_status] ${entry.config.label}: ${prev || '(none)'} → ${event.taskStatus} @ ${entry.state.taskStatusChangedAt}`);
129
+ }
130
+ }
119
131
  });
120
132
  // Handle watch events
121
133
  this.watchManager.on('watch_alert', (event) => {
134
+ const alertEntry = this.agents.get(event.agentId);
135
+ // Paused source smith — observation continues but no dispatch.
136
+ if (alertEntry?.state.paused) {
137
+ console.log(`[watch] ${alertEntry.config.label}: paused — alert dropped`);
138
+ return;
139
+ }
122
140
  this.emit('event', event);
123
141
  // Push alert to agent history so Log panel shows it
124
- const alertEntry = this.agents.get(event.agentId);
125
142
  if (alertEntry && event.entry) {
126
143
  alertEntry.state.history.push(event.entry);
127
144
  this.emit('event', { type: 'log', agentId: event.agentId, entry: event.entry } as any);
@@ -266,6 +283,21 @@ export class WorkspaceOrchestrator extends EventEmitter {
266
283
  return null;
267
284
  }
268
285
 
286
+ /** Resolve the primary smith's fixed session for this project. If none is bound
287
+ * yet, auto-bind to the latest existing claude session so the terminal picker
288
+ * can offer "Current Session" instead of forcing the user into a fresh shell. */
289
+ resolvePrimaryFixedSession(): string | null {
290
+ const existing = getFixedSession(this.projectPath);
291
+ if (existing) return existing;
292
+ const primary = this.getPrimaryAgent();
293
+ if (!primary) return null;
294
+ const latest = this.getLatestSessionId(primary.config.workDir);
295
+ if (!latest) return null;
296
+ setFixedSession(this.projectPath, latest);
297
+ console.log(`[workspace] primary auto-bound to existing session ${latest} for ${this.projectPath}`);
298
+ return latest;
299
+ }
300
+
269
301
  addAgent(config: WorkspaceAgentConfig): void {
270
302
  const conflict = this.validateOutputs(config);
271
303
  if (conflict) throw new Error(conflict);
@@ -440,7 +472,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
440
472
  const workerState = entry.worker?.getState();
441
473
  // Merge: worker state for task/smith, entry.state for mode (orchestrator controls mode)
442
474
  result[id] = workerState
443
- ? { ...workerState, taskStatus: entry.state.taskStatus, tmuxSession: entry.state.tmuxSession, currentMessageId: entry.state.currentMessageId }
475
+ ? { ...workerState, taskStatus: entry.state.taskStatus, tmuxSession: entry.state.tmuxSession, currentMessageId: entry.state.currentMessageId, paused: entry.state.paused }
444
476
  : entry.state;
445
477
  }
446
478
  return result;
@@ -930,6 +962,9 @@ export class WorkspaceOrchestrator extends EventEmitter {
930
962
  // Clean up stale state from previous run
931
963
  this.bus.markAllRunningAsFailed();
932
964
 
965
+ // Paused is transient — clear on every daemon start so any leftover flag goes away.
966
+ for (const entry of this.agents.values()) entry.state.paused = false;
967
+
933
968
  // Install forge skills globally (once per daemon start)
934
969
  try {
935
970
  installForgeSkills(this.projectPath, this.workspaceId, '', Number(process.env.PORT) || 8403);
@@ -1167,6 +1202,9 @@ export class WorkspaceOrchestrator extends EventEmitter {
1167
1202
  for (const [id, entry] of this.agents) {
1168
1203
  if (entry.config.type === 'input') continue;
1169
1204
 
1205
+ // 0. Clear transient paused flag — daemon stop should always reset it.
1206
+ entry.state.paused = false;
1207
+
1170
1208
  // 1. Stop message loop
1171
1209
  this.stopMessageLoop(id);
1172
1210
 
@@ -1633,16 +1671,40 @@ export class WorkspaceOrchestrator extends EventEmitter {
1633
1671
  return this.daemonActive;
1634
1672
  }
1635
1673
 
1636
- /** Pause a running agent */
1674
+ /** Pause a smith drop pending/incoming bus messages as failed, suppress watch alerts.
1675
+ * In-flight task continues. Resume does NOT replay dropped messages.
1676
+ * Transient: any daemon stop/start or process restart clears the flag. */
1637
1677
  pauseAgent(agentId: string): void {
1638
1678
  const entry = this.agents.get(agentId);
1639
- entry?.worker?.pause();
1679
+ if (!entry) return;
1680
+ entry.state.paused = true;
1681
+ entry.worker?.pause();
1682
+
1683
+ // Drain inbox: anything queued for this smith → failed (visible in inbox).
1684
+ let drained = 0;
1685
+ for (const m of this.bus.getLog()) {
1686
+ if (m.to !== agentId || m.type === 'ack') continue;
1687
+ if (m.status === 'pending' || m.status === 'pending_approval') {
1688
+ m.status = 'failed' as any;
1689
+ drained++;
1690
+ this.emit('event', { type: 'bus_message_status', messageId: m.id, status: 'failed' } as any);
1691
+ }
1692
+ }
1693
+
1694
+ this.emit('event', { type: 'task_status', agentId, taskStatus: entry.state.taskStatus } as any);
1695
+ this.emitAgentsChanged();
1696
+ console.log(`[workspace] ${entry.config.label}: paused${drained ? ` (${drained} pending message(s) dropped to failed)` : ''}`);
1640
1697
  }
1641
1698
 
1642
- /** Resume a paused agent */
1699
+ /** Resume a paused smith — clear flag, re-enable bus pickup and watch dispatch. */
1643
1700
  resumeAgent(agentId: string): void {
1644
1701
  const entry = this.agents.get(agentId);
1645
- entry?.worker?.resume();
1702
+ if (!entry) return;
1703
+ entry.state.paused = false;
1704
+ entry.worker?.resume();
1705
+ this.emit('event', { type: 'task_status', agentId, taskStatus: entry.state.taskStatus } as any);
1706
+ this.emitAgentsChanged();
1707
+ console.log(`[workspace] ${entry.config.label}: resumed`);
1646
1708
  }
1647
1709
 
1648
1710
  /** Stop a running agent */
@@ -2902,6 +2964,14 @@ Silently ingest this context. Do NOT respond — await an actual task.`;
2902
2964
  // ── Store message in agent history ──
2903
2965
  target.state.history.push(logEntry);
2904
2966
 
2967
+ // ── Paused smith → drop as failed; user retries/deletes from inbox. ──
2968
+ if (target.state.paused) {
2969
+ msg.status = 'failed' as any;
2970
+ this.emit('event', { type: 'bus_message_status', messageId: msg.id, status: 'failed' } as any);
2971
+ console.log(`[bus] ${target.config.label}: paused — ${action} dropped to failed`);
2972
+ return;
2973
+ }
2974
+
2905
2975
  // ── requiresApproval → set pending_approval on arrival ──
2906
2976
  if (target.config.requiresApproval) {
2907
2977
  msg.status = 'pending_approval';
@@ -2933,6 +3003,9 @@ Silently ingest this context. Do NOT respond — await an actual task.`;
2933
3003
  // (loop stays alive so it works when smith comes back)
2934
3004
  if (entry.state.smithStatus !== 'active') return;
2935
3005
 
3006
+ // Paused smiths refuse new bus pickups; loop stays alive for resume.
3007
+ if (entry.state.paused) return;
3008
+
2936
3009
  // Skip if already busy
2937
3010
  if (entry.state.taskStatus === 'running') return;
2938
3011
 
@@ -53,6 +53,9 @@ export async function saveWorkspace(state: WorkspaceState): Promise<void> {
53
53
  ...s,
54
54
  history: [],
55
55
  logFile: agentLogFile(state.id, id),
56
+ paused: undefined, // transient — never persisted
57
+ taskStatusChangedAt: undefined, // transient
58
+ lastTaskStatus: undefined, // transient
56
59
  }])
57
60
  ),
58
61
  updatedAt: Date.now(),
@@ -90,6 +93,9 @@ export function saveWorkspaceSync(state: WorkspaceState): void {
90
93
  ...s,
91
94
  history: [],
92
95
  logFile: agentLogFile(state.id, id),
96
+ paused: undefined, // transient — never persisted
97
+ taskStatusChangedAt: undefined, // transient
98
+ lastTaskStatus: undefined, // transient
93
99
  }])
94
100
  ),
95
101
  updatedAt: Date.now(),
@@ -160,6 +166,16 @@ export function loadWorkspace(workspaceId: string): WorkspaceState | null {
160
166
  if (agentState.taskStatus === 'running') {
161
167
  agentState.taskStatus = 'idle';
162
168
  }
169
+
170
+ // Defensive: paused is transient. Force false on load in case any
171
+ // older state.json still has it persisted.
172
+ agentState.paused = false;
173
+
174
+ // Init the agent_status watch fields. Setting changedAt to now() means
175
+ // any transitions that happen after load advance the timestamp; watchers
176
+ // record this baseline at their first tick and won't spuriously fire.
177
+ agentState.taskStatusChangedAt = Date.now();
178
+ agentState.lastTaskStatus = agentState.taskStatus;
163
179
  }
164
180
 
165
181
  // Migrate Input nodes: content → entries
@@ -99,6 +99,19 @@ export interface AgentState {
99
99
  // ─── Task layer (current work) ──────────
100
100
  taskStatus: TaskStatus; // idle/running/done/failed
101
101
 
102
+ // Transient runtime flag — paused smith ignores bus pickups, drops new
103
+ // incoming messages as failed, and suppresses watch alerts. Never persisted;
104
+ // any daemon stop/start or process restart clears it.
105
+ paused?: boolean;
106
+
107
+ // Transient: timestamp of the most recent taskStatus *change* (idempotent
108
+ // re-emits of the same value don't update this). Used by agent_status watches
109
+ // to detect transitions without polling. Reset to Date.now() on load.
110
+ taskStatusChangedAt?: number;
111
+ // Transient: previous taskStatus value, used by orchestrator's event listener
112
+ // to dedup idempotent emits. Reset to current taskStatus on load.
113
+ lastTaskStatus?: TaskStatus;
114
+
102
115
  // ─── Execution details ──────────────────
103
116
  currentStep?: number;
104
117
  history: TaskLogEntry[];
@@ -21,10 +21,18 @@ interface WatchSnapshot {
21
21
  gitHash?: string;
22
22
  commandOutput?: string;
23
23
  logLineCount?: number; // last known line count in agent's logs.jsonl
24
- agentStatus?: string; // last known taskStatus of monitored agent
24
+ /** Per-target last-seen taskStatusChangedAt. Watch fires when the target's
25
+ * current taskStatusChangedAt > the value recorded here. Updated only after
26
+ * a successful (non-defer) match-check. */
27
+ agentStatusLastSeen?: Record<string, number>;
25
28
  sessionFileSize?: number; // last known file size of session JSONL (bytes)
26
29
  }
27
30
 
31
+ /** Statuses that mean "still working — defer firing until it settles". */
32
+ function isBusyStatus(s: string | undefined): boolean {
33
+ return s === 'running' || s === 'starting';
34
+ }
35
+
28
36
  interface WatchChange {
29
37
  targetType: WatchTarget['type'];
30
38
  description: string;
@@ -330,7 +338,7 @@ export class WatchManager extends EventEmitter {
330
338
  constructor(
331
339
  private workspaceId: string,
332
340
  private projectPath: string,
333
- private getAgents: () => Map<string, { config: WorkspaceAgentConfig; state: { smithStatus: string; taskStatus: string; mode: string } }>,
341
+ private getAgents: () => Map<string, { config: WorkspaceAgentConfig; state: { smithStatus: string; taskStatus: string; mode?: string; taskStatusChangedAt?: number } }>,
334
342
  ) {
335
343
  super();
336
344
  }
@@ -346,9 +354,7 @@ export class WatchManager extends EventEmitter {
346
354
 
347
355
  /** Stop all watch loops */
348
356
  stop(): void {
349
- for (const [id, timer] of this.timers) {
350
- clearInterval(timer);
351
- }
357
+ for (const timer of this.timers.values()) clearInterval(timer);
352
358
  this.timers.clear();
353
359
  console.log(`[watch] All watch loops stopped`);
354
360
  }
@@ -392,7 +398,9 @@ export class WatchManager extends EventEmitter {
392
398
  const now = Date.now();
393
399
  const prev = this.snapshots.get(agentId) || { lastCheckTime: now };
394
400
  const allChanges: WatchChange[] = [];
395
- const newSnapshot: WatchSnapshot = { lastCheckTime: now };
401
+ const newSnapshot: WatchSnapshot = {
402
+ lastCheckTime: now,
403
+ };
396
404
 
397
405
  for (const target of config.watch!.targets) {
398
406
  switch (target.type) {
@@ -456,25 +464,58 @@ export class WatchManager extends EventEmitter {
456
464
  break;
457
465
  }
458
466
  case 'agent_status': {
459
- // Monitor another agent's task status (running done/failed)
460
- const targetAgentId = target.path; // path = agent ID to monitor
461
- if (targetAgentId) {
462
- const agents = this.getAgents();
463
- const targetEntry = agents.get(targetAgentId);
464
- if (targetEntry) {
465
- const currentStatus = targetEntry.state.taskStatus;
466
- const prevStatus = prev.agentStatus;
467
- newSnapshot.agentStatus = currentStatus;
468
- if (prevStatus && prevStatus !== currentStatus) {
469
- const label = targetEntry.config.label;
470
- // Match pattern if specified (e.g., "done" or "failed")
471
- const pattern = target.pattern;
472
- if (!pattern || currentStatus.match(new RegExp(pattern, 'i'))) {
473
- allChanges.push({ targetType: 'agent_status', description: `Agent ${label} status: ${prevStatus} → ${currentStatus}`, files: [] });
474
- }
475
- }
476
- }
467
+ // Event-driven detection via taskStatusChangedAt timestamp on the target
468
+ // agent's state (orchestrator updates it on every real transition).
469
+ // The watch tick just compares timestamps — no fast-tick polling needed.
470
+ const targetAgentId = target.path;
471
+ if (!targetAgentId) break;
472
+ const agents = this.getAgents();
473
+ const targetEntry = agents.get(targetAgentId);
474
+ if (!targetEntry) break;
475
+
476
+ const cur = targetEntry.state.taskStatus;
477
+ const curChangedAt = targetEntry.state.taskStatusChangedAt || 0;
478
+ const lastSeenMap = newSnapshot.agentStatusLastSeen || (newSnapshot.agentStatusLastSeen = { ...(prev.agentStatusLastSeen || {}) });
479
+
480
+ // Initial run: record current changedAt so we don't fire on the
481
+ // existing state. Future transitions will advance the timestamp.
482
+ if (initialRun) {
483
+ lastSeenMap[targetAgentId] = curChangedAt;
484
+ console.log(`[watch] ${config.label}: agent_status baseline — ${targetEntry.config.label}=${cur} @ ${curChangedAt}`);
485
+ break;
486
+ }
487
+
488
+ const prevSeen = lastSeenMap[targetAgentId] || 0;
489
+
490
+ // No transition since last check.
491
+ if (curChangedAt <= prevSeen) {
492
+ console.log(`[watch] ${config.label}: ${targetEntry.config.label} no transition since last check (curAt=${curChangedAt})`);
493
+ break;
494
+ }
495
+
496
+ // Target is mid-transition (still busy) — defer; do NOT update lastSeen
497
+ // so the next interval-tick can still see this as a pending transition.
498
+ if (isBusyStatus(cur)) {
499
+ console.log(`[watch] ${config.label}: ${targetEntry.config.label} = ${cur} (busy) → defer`);
500
+ break;
501
+ }
502
+
503
+ // Settled state. Match pattern against final state only.
504
+ const pattern = target.pattern;
505
+ const matched = pattern ? !!cur.match(new RegExp(pattern, 'i')) : true;
506
+ if (matched) {
507
+ console.log(`[watch] ${config.label}: ${targetEntry.config.label} → ${cur} (changed since ${prevSeen}) pattern='${pattern || '(any)'}' → FIRE`);
508
+ allChanges.push({
509
+ targetType: 'agent_status',
510
+ description: `Agent ${targetEntry.config.label} settled to ${cur}${pattern ? ` (matched '${pattern}')` : ''}`,
511
+ files: [],
512
+ });
513
+ } else {
514
+ console.log(`[watch] ${config.label}: ${targetEntry.config.label} → ${cur} pattern='${pattern}' → no match`);
477
515
  }
516
+ // Mark this transition as seen, so we don't fire again until the next
517
+ // real transition advances the timestamp.
518
+ lastSeenMap[targetAgentId] = curChangedAt;
478
519
  break;
479
520
  }
480
521
  }
@@ -312,10 +312,9 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
312
312
  if (body.resolveOnly) {
313
313
  let currentSessionId: string | null = null;
314
314
  if (agentConfig.primary) {
315
- try {
316
- const { getFixedSession } = await import('./project-sessions.js');
317
- currentSessionId = getFixedSession(orch.projectPath) || null;
318
- } catch {}
315
+ // Auto-bind to project's latest existing session if no fixedSession yet,
316
+ // so the picker can offer "Current Session" on a fresh workspace.
317
+ currentSessionId = orch.resolvePrimaryFixedSession();
319
318
  } else {
320
319
  currentSessionId = agentConfig.boundSessionId || null;
321
320
  }
@@ -744,11 +743,8 @@ async function handleSmith(id: string, body: any, res: ServerResponse): Promise<
744
743
  // Get the primary agent's tmux session + project-level fixed session
745
744
  const primary = orch.getPrimaryAgent();
746
745
  if (!primary) return json(res, { ok: false, error: 'No primary agent configured' });
747
- let fixedSessionId: string | null = null;
748
- try {
749
- const { getFixedSession } = await import('./project-sessions.js');
750
- fixedSessionId = getFixedSession(orch.projectPath) || null;
751
- } catch {}
746
+ // Auto-bind to project's latest existing session if no fixedSession yet.
747
+ const fixedSessionId = orch.resolvePrimaryFixedSession();
752
748
  return json(res, {
753
749
  ok: true,
754
750
  agentId: primary.config.id,
package/next-env.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /// <reference types="next" />
2
2
  /// <reference types="next/image-types/global" />
3
- import "./.next/types/routes.d.ts";
3
+ import "./.next/dev/types/routes.d.ts";
4
4
 
5
5
  // NOTE: This file should not be edited
6
6
  // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.5.42",
3
+ "version": "0.5.43",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {