@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 +7 -4
- package/components/Dashboard.tsx +8 -1
- package/components/WorkspaceView.tsx +15 -2
- package/lib/help-docs/11-workspace.md +2 -1
- package/lib/workspace/orchestrator.ts +80 -7
- package/lib/workspace/persistence.ts +16 -0
- package/lib/workspace/types.ts +13 -0
- package/lib/workspace/watch-manager.ts +65 -24
- package/lib/workspace-standalone.ts +5 -9
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
# Forge v0.5.
|
|
1
|
+
# Forge v0.5.43
|
|
2
2
|
|
|
3
|
-
Released: 2026-04-
|
|
3
|
+
Released: 2026-04-25
|
|
4
4
|
|
|
5
|
-
## Changes since v0.5.
|
|
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
|
-
|
|
10
|
+
|
|
11
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.42...v0.5.43
|
package/components/Dashboard.tsx
CHANGED
|
@@ -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)]">
|
|
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/
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
package/lib/workspace/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 = {
|
|
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
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
748
|
-
|
|
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