@turmind/halo 2.2.0 → 2.2.1
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/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/1989-3b2faa2731d973c4.js +1 -0
- package/admin-out/_next/static/chunks/app/page-10a4afac9cbc27a2.js +1 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +4 -4
- package/bundled-docs/design/background-dispatch.md +1 -1
- package/bundled-docs/design/cron.md +4 -4
- package/bundled-docs/design/session.md +2 -2
- package/bundled-docs/design/ws.md +7 -5
- package/bundled-docs/dev/api.md +4 -3
- package/bundled-docs/guide/agents.md +1 -1
- package/bundled-docs/requirements/agent.md +1 -1
- package/bundled-docs/requirements/workspace.md +3 -3
- package/dist/index.js +72 -24
- package/package.json +1 -1
- package/templates/skills/cron/SKILL.md +46 -21
- package/templates/skills/cron/manage-cron.py +10 -13
- package/admin-out/_next/static/chunks/1989-dab5d5c24b8f4c9f.js +0 -1
- package/admin-out/_next/static/chunks/app/page-cca81a2ed69100f5.js +0 -1
- /package/admin-out/_next/static/{Rs9tIYSSITPLxv274DU9f → j4QFRrUtg33DNMUIgg4ij}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{Rs9tIYSSITPLxv274DU9f → j4QFRrUtg33DNMUIgg4ij}/_ssgManifest.js +0 -0
package/admin-out/index.html
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<!DOCTYPE html><!--
|
|
1
|
+
<!DOCTYPE html><!--j4QFRrUtg33DNMUIgg4ij--><html lang="en" class="dark"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="preload" href="/_next/static/media/e4af272ccee01ff0-s.p.woff2" as="font" crossorigin="" type="font/woff2"/><link rel="stylesheet" href="/_next/static/css/7b736202f0b1dbfd.css" data-precedence="next"/><link rel="stylesheet" href="/_next/static/css/8fc43fce83e5ce5d.css" data-precedence="next"/><link rel="stylesheet" href="/_next/static/css/659eccb5db697b76.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-9532ec7f1fa9a757.js"/><script src="/_next/static/chunks/1b86ba38-4193816aebc732b9.js" async=""></script><script src="/_next/static/chunks/9698-0157e7ac70c3f9fc.js" async=""></script><script src="/_next/static/chunks/main-app-5e981adb363c52e5.js" async=""></script><script src="/_next/static/chunks/1989-3b2faa2731d973c4.js" async=""></script><script src="/_next/static/chunks/app/layout-0ed29966e0abc26b.js" async=""></script><script src="/_next/static/chunks/1d2d5650-c677408087984c7a.js" async=""></script><script src="/_next/static/chunks/1607-bf227fc5e4db45b7.js" async=""></script><script src="/_next/static/chunks/app/page-10a4afac9cbc27a2.js" async=""></script><meta name="next-size-adjust" content=""/><title>Halo - Multi-Agent Workspace</title><meta name="description" content="Multi-agent collaboration workspace for complex project delivery"/><link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16"/><link rel="icon" href="/icon.svg?3a6863548daca964" type="image/svg+xml" sizes="any"/><script>try{var t=localStorage.getItem('halo_theme');if(t==='light'||t==='midnight'||t==='warm')document.documentElement.dataset.theme=t}catch(e){}</script><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body class="__variable_d8ebd1 font-sans antialiased"><div hidden=""><!--$--><!--/$--></div><div class="flex h-screen w-screen items-center justify-center bg-[var(--background)]"><div class="text-sm text-[var(--muted-foreground)]">Loading...</div></div><!--$--><!--/$--><script src="/_next/static/chunks/webpack-9532ec7f1fa9a757.js" id="_R_" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0])</script><script>self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[58321,[\"1989\",\"static/chunks/1989-3b2faa2731d973c4.js\",\"7177\",\"static/chunks/app/layout-0ed29966e0abc26b.js\"],\"ThemeProvider\"]\n3:I[24554,[\"1989\",\"static/chunks/1989-3b2faa2731d973c4.js\",\"7177\",\"static/chunks/app/layout-0ed29966e0abc26b.js\"],\"AppI18nProvider\"]\n4:I[63693,[],\"\"]\n5:I[74713,[],\"\"]\n6:I[5444,[],\"ClientPageRoot\"]\n7:I[47333,[\"4560\",\"static/chunks/1d2d5650-c677408087984c7a.js\",\"1607\",\"static/chunks/1607-bf227fc5e4db45b7.js\",\"1989\",\"static/chunks/1989-3b2faa2731d973c4.js\",\"8974\",\"static/chunks/app/page-10a4afac9cbc27a2.js\"],\"default\"]\na:I[23632,[],\"OutletBoundary\"]\nc:I[86889,[],\"AsyncMetadataOutlet\"]\ne:I[23632,[],\"ViewportBoundary\"]\n10:I[23632,[],\"MetadataBoundary\"]\n11:\"$Sreact.suspense\"\n13:I[1935,[],\"\"]\n:HL[\"/_next/static/media/e4af272ccee01ff0-s.p.woff2\",\"font\",{\"crossOrigin\":\"\",\"type\":\"font/woff2\"}]\n:HL[\"/_next/static/css/7b736202f0b1dbfd.css\",\"style\"]\n:HL[\"/_next/static/css/8fc43fce83e5ce5d.css\",\"style\"]\n:HL[\"/_next/static/css/659eccb5db697b76.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"0:{\"P\":null,\"b\":\"j4QFRrUtg33DNMUIgg4ij\",\"p\":\"\",\"c\":[\"\",\"\"],\"i\":false,\"f\":[[[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],[\"\",[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/7b736202f0b1dbfd.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}],[\"$\",\"link\",\"1\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/8fc43fce83e5ce5d.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"className\":\"dark\",\"suppressHydrationWarning\":true,\"children\":[[\"$\",\"head\",null,{\"children\":[\"$\",\"script\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"try{var t=localStorage.getItem('halo_theme');if(t==='light'||t==='midnight'||t==='warm')document.documentElement.dataset.theme=t}catch(e){}\"}}]}],[\"$\",\"body\",null,{\"className\":\"__variable_d8ebd1 font-sans antialiased\",\"children\":[\"$\",\"$L2\",null,{\"children\":[\"$\",\"$L3\",null,{\"children\":[\"$\",\"$L4\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L5\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],[]],\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]}]}]}]]}]]}],{\"children\":[\"__PAGE__\",[\"$\",\"$1\",\"c\",{\"children\":[[\"$\",\"$L6\",null,{\"Component\":\"$7\",\"searchParams\":{},\"params\":{},\"promises\":[\"$@8\",\"$@9\"]}],[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/659eccb5db697b76.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"$La\",null,{\"children\":[\"$Lb\",[\"$\",\"$Lc\",null,{\"promise\":\"$@d\"}]]}]]}],{},null,false]},null,false],[\"$\",\"$1\",\"h\",{\"children\":[null,[[\"$\",\"$Le\",null,{\"children\":\"$Lf\"}],[\"$\",\"meta\",null,{\"name\":\"next-size-adjust\",\"content\":\"\"}]],[\"$\",\"$L10\",null,{\"children\":[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$11\",null,{\"fallback\":null,\"children\":\"$L12\"}]}]}]]}],false]],\"m\":\"$undefined\",\"G\":[\"$13\",[]],\"s\":false,\"S\":true}\n"])</script><script>self.__next_f.push([1,"8:{}\n9:\"$0:f:0:1:2:children:1:props:children:0:props:params\"\n"])</script><script>self.__next_f.push([1,"f:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]\nb:null\n"])</script><script>self.__next_f.push([1,"14:I[88809,[],\"IconMark\"]\nd:{\"metadata\":[[\"$\",\"title\",\"0\",{\"children\":\"Halo - Multi-Agent Workspace\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"Multi-agent collaboration workspace for complex project delivery\"}],[\"$\",\"link\",\"2\",{\"rel\":\"icon\",\"href\":\"/favicon.ico\",\"type\":\"image/x-icon\",\"sizes\":\"16x16\"}],[\"$\",\"link\",\"3\",{\"rel\":\"icon\",\"href\":\"/icon.svg?3a6863548daca964\",\"type\":\"image/svg+xml\",\"sizes\":\"any\"}],[\"$\",\"$L14\",\"4\",{}]],\"error\":null,\"digest\":\"$undefined\"}\n"])</script><script>self.__next_f.push([1,"12:\"$d:metadata\"\n"])</script></body></html>
|
package/admin-out/index.txt
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
1:"$Sreact.fragment"
|
|
2
|
-
2:I[58321,["1989","static/chunks/1989-
|
|
3
|
-
3:I[24554,["1989","static/chunks/1989-
|
|
2
|
+
2:I[58321,["1989","static/chunks/1989-3b2faa2731d973c4.js","7177","static/chunks/app/layout-0ed29966e0abc26b.js"],"ThemeProvider"]
|
|
3
|
+
3:I[24554,["1989","static/chunks/1989-3b2faa2731d973c4.js","7177","static/chunks/app/layout-0ed29966e0abc26b.js"],"AppI18nProvider"]
|
|
4
4
|
4:I[63693,[],""]
|
|
5
5
|
5:I[74713,[],""]
|
|
6
6
|
6:I[5444,[],"ClientPageRoot"]
|
|
7
|
-
7:I[
|
|
7
|
+
7:I[47333,["4560","static/chunks/1d2d5650-c677408087984c7a.js","1607","static/chunks/1607-bf227fc5e4db45b7.js","1989","static/chunks/1989-3b2faa2731d973c4.js","8974","static/chunks/app/page-10a4afac9cbc27a2.js"],"default"]
|
|
8
8
|
a:I[23632,[],"OutletBoundary"]
|
|
9
9
|
c:I[86889,[],"AsyncMetadataOutlet"]
|
|
10
10
|
e:I[23632,[],"ViewportBoundary"]
|
|
@@ -15,7 +15,7 @@ e:I[23632,[],"ViewportBoundary"]
|
|
|
15
15
|
:HL["/_next/static/css/7b736202f0b1dbfd.css","style"]
|
|
16
16
|
:HL["/_next/static/css/8fc43fce83e5ce5d.css","style"]
|
|
17
17
|
:HL["/_next/static/css/659eccb5db697b76.css","style"]
|
|
18
|
-
0:{"P":null,"b":"
|
|
18
|
+
0:{"P":null,"b":"j4QFRrUtg33DNMUIgg4ij","p":"","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/7b736202f0b1dbfd.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}],["$","link","1",{"rel":"stylesheet","href":"/_next/static/css/8fc43fce83e5ce5d.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"en","className":"dark","suppressHydrationWarning":true,"children":[["$","head",null,{"children":["$","script",null,{"dangerouslySetInnerHTML":{"__html":"try{var t=localStorage.getItem('halo_theme');if(t==='light'||t==='midnight'||t==='warm')document.documentElement.dataset.theme=t}catch(e){}"}}]}],["$","body",null,{"className":"__variable_d8ebd1 font-sans antialiased","children":["$","$L2",null,{"children":["$","$L3",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L6",null,{"Component":"$7","searchParams":{},"params":{},"promises":["$@8","$@9"]}],[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/659eccb5db697b76.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","$La",null,{"children":["$Lb",["$","$Lc",null,{"promise":"$@d"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$Le",null,{"children":"$Lf"}],["$","meta",null,{"name":"next-size-adjust","content":""}]],["$","$L10",null,{"children":["$","div",null,{"hidden":true,"children":["$","$11",null,{"fallback":null,"children":"$L12"}]}]}]]}],false]],"m":"$undefined","G":["$13",[]],"s":false,"S":true}
|
|
19
19
|
8:{}
|
|
20
20
|
9:"$0:f:0:1:2:children:1:props:children:0:props:params"
|
|
21
21
|
f:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
|
@@ -95,7 +95,7 @@ subscribe handler
|
|
|
95
95
|
│ (other subscriptions untouched)
|
|
96
96
|
├─ Load UIState from SessionManager (or from file if not in memory)
|
|
97
97
|
│
|
|
98
|
-
└─ Send state:snapshot carrying the
|
|
98
|
+
└─ Send state:snapshot carrying the messageLog (minus tool_call / tool_result rows)
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
## When disconnect happens while background is still running
|
|
@@ -64,7 +64,7 @@ A fire runs **in-process**: the prompt goes into the job's session on the server
|
|
|
64
64
|
4. **Create or resume** the session. Missing → `createSession(job.agentId, null, 'Cron: <label|id>', undefined, sid, folderAbs, null[, title])` — access `null` = full; the display name resolves from agent.yaml; a webhook job's session is titled `Webhook: <label|id>` up front (its first message is a payload), a scheduled one takes its title from the first message. Existing with another agent → `failed` (`session X runs agent Y, not the job's Z; …`). Existing with the same agent → `setWorkingDir(sid, folderAbs|null)` re-points its folder in place (changing a job's folder moves its session too) — except a webhook fire on a busy session, which leaves it for a later run (see [Webhook trigger](#webhook-trigger)); its **access level is kept** — the send passes no level.
|
|
65
65
|
5. **Re-check, then take the pointer** — past the awaits `sessionBusy` runs again, then in the same synchronous slice `takeReplyTo(db, sid, { cron: runId })`.
|
|
66
66
|
6. **Track** (`trackRun`, before the send — the turn can end before `sendUserMessage` resolves): `_runs` entry, the root event listener (notices, interrupted replies), the timeout timer.
|
|
67
|
-
7. **Send**: `appendUserMessage(sid, job.userPrompt)` (the UI row shows the prompt alone) and `sendUserMessage(sid, UNATTENDED_PREAMBLE + prompt, …, stamp)
|
|
67
|
+
7. **Send**: `appendUserMessage(sid, job.userPrompt)` (the UI row shows the prompt alone) and `sendUserMessage(sid, UNATTENDED_PREAMBLE + prompt, …, stamp)`; a webhook fire appends each claimed event's text to both, and an empty prompt (allowed on webhook jobs only) is left out of both — the UI row is the events alone, the model text the preamble + events. A run-now of an empty-prompt job is recorded `skipped` (`nothing to run`): it carries no events. The `[Unattended cron run — nobody will answer questions …]` line is added on every fire: the model can't see the `cron-*` id, and prompts typed into the admin form carry no such notice. A send that throws → own pointer cleared, `failed`. `runJob` returns the runId here; it does not wait for the run.
|
|
68
68
|
|
|
69
69
|
**Session strategy.** A stateful job runs in one stable session — its own by default, or an existing **root** session picked on the job (`cron_jobs.session_id`, `CRON_MIGRATIONS` slot 1). The job's own session id **is the job id** (generated ids are already `cron-<ts>-<rand>`; a custom id without that prefix gets `cron-<id>`) — `effectiveSessionId` in `runner.ts` is the one copy of the rule, returned by the REST list as `effectiveSessionId`. Older builds always prefixed (`cron-cron-…`); slot 3 (v4) pins every job that ever ran to that session via `session_id`. The conversation accumulates and is reviewable (live) in the Sessions tab. A **stateless** job (`cron_jobs.stateless`) gets a fresh session every fire.
|
|
70
70
|
|
|
@@ -99,7 +99,7 @@ A run not resumed hands its webhook events back to pending; at the end every job
|
|
|
99
99
|
|
|
100
100
|
## Webhook trigger
|
|
101
101
|
|
|
102
|
-
The third trigger mode (`CRON_MIGRATIONS` slot 6, v7): `cron_jobs.webhook_secret` non-null = a webhook job — no schedule, no `runAt`, no timer (`scheduleJob` parks it in `_expired`; `jobFingerprint` includes the flag). Excludes
|
|
102
|
+
The third trigger mode (`CRON_MIGRATIONS` slot 6, v7): `cron_jobs.webhook_secret` non-null = a webhook job — no schedule, no `runAt`, no timer (`scheduleJob` parks it in `_expired`; `jobFingerprint` includes the flag). Excludes an active window, at every write surface. A picked session works as for a timer job (stateless still excludes it, the agent lock and root-only id apply): while the session is owed to another owner (relay / A2A / an invocation / another job's run) the events wait, and that owner's `clearReplyTo` wakes them (the door's `released`, see `startOrInject` below).
|
|
103
103
|
|
|
104
104
|
**Receiver** — `POST /cron-hooks/:jobId` (`routes/cron-hooks.ts`, outside `/api`, public; mounted only when not AgentCore). Body ≤ 1 MB (413). Auth: GitHub `X-Hub-Signature-256` (HMAC-SHA256 of the raw body) or `Authorization: Bearer <secret>`, both `timingSafeEqual`; no job / not a webhook job / bad auth are one `404`. A GitHub `ping` → 200 before the enabled check (so creating the hook on a disabled job passes); disabled → 409. Delivery id = `X-GitHub-Delivery` / `Idempotency-Key` / `X-Request-Id`, else `sha256:<hex of body>#<receivedAt>` — matched by prefix within the last 10 minutes (`BODY_DEDUPE_MS`), so a header-less sender's retry burst is one event and the same body later a new one; the receipt time keeps it clear of `UNIQUE(job_id, delivery_id)`. A duplicate → 200 `{duplicate:true, eventId}` (insert `onConflictDoNothing` for a race) — except a repeat of a `failed` event: flipped back to `pending` (run / session / stamp cleared) and handled like a new one, 202 `{eventId, redelivered:true}`. More than 100 pending → 429. Stored → 202 `{eventId}`, then `startOrInject(jobId)` asynchronously.
|
|
105
105
|
|
|
@@ -107,7 +107,7 @@ The third trigger mode (`CRON_MIGRATIONS` slot 6, v7): `cron_jobs.webhook_secret
|
|
|
107
107
|
|
|
108
108
|
**`startOrInject(jobId)`** — batching:
|
|
109
109
|
- A run of the job is tracked here and still owns its session → claim every pending event and inject each as its own message (`appendUserMessage` + `stampEvents` + `sendUserMessage` with the stamp): folded into the running turn or queued, so one run reports a batch. An inject that throws puts that event back to pending. A manual run (run-now) counts: events arriving during it are injected into it, though its opening message carried the prompt alone.
|
|
110
|
-
- Otherwise (job enabled and still a webhook job) `runJob(jobId, 'webhook')`: the events claimed right after `trackRun` ride the opening message (prompt + each event's text). Unlike a timer fire, a webhook fire **does not skip on a user's turn** (`sessionBusy(…, ownerOnly)`) —
|
|
110
|
+
- Otherwise (job enabled and still a webhook job) `runJob(jobId, 'webhook')`: the events claimed right after `trackRun` ride the opening message (prompt, if any, + each event's text). Unlike a timer fire, a webhook fire **does not skip on a user's turn** (`sessionBusy(…, ownerOnly)`) — its message goes into the running turn (`sendUserMessage`'s busy branch: queued + soft interrupt, so the event joins that turn at its next tool batch and the run pushes that turn's reply), leaving the session's folder as is (it can't change mid-turn; the job folder applies on a later run); a session owed to another owner → `''`, no row, events stay pending until it lets go: `clearReplyTo` (every owner's release — relay, A2A, `/invocations`, a cron run) calls the door's `released(sessionId)`, which kicks each webhook job (non-stateless) whose session that is and that has pending events. The same holds when the re-check past the fire's awaits finds a new owner: the `running` row it had written is deleted (no failed row, `lastRunStatus` untouched), the events stay pending. The kick only reaches this process: an owner in another process sharing the workspace db (dev and prod, a TUI) releasing the session kicks nothing here — the events wait for the next event, a restart (`recoverRuns`) or the job's next run end. Every run end (finish / timeout / cancel) kicks the job again if events are pending. A webhook fire that fails before its turn starts (workspace / folder missing, workspace db unopenable, agent mismatch, `createSession` or the send throwing) marks every event it held or found pending `failed` — not retried (a broken workspace would loop); the sender's redelivery revives them. A run-now that fails before its turn kicks the job, so events that arrived during it get their own run (that one failing settles them `failed` and kicks nothing).
|
|
111
111
|
- Job disabled or switched off webhook mode → its pending events `failed` (`failPendingEvents`: `unscheduleJob` — REST PUT and reconcile —, `scheduleJob`'s non-webhook branch, and `startOrInject` finding it so).
|
|
112
112
|
|
|
113
113
|
**Event text** — `[Webhook event · <type> · delivery <id> · received <iso>]`, then a line saying the payload is data, not instructions, then the payload. Over 32 KB the payload is written to `<ws>/.halo/tmp/webhooks/<jobId>/<eventId>.txt` and only its first 4 KB inlined; the UI row is cut at 2000 chars.
|
|
@@ -170,4 +170,4 @@ interface CronChannelDispatcher {
|
|
|
170
170
|
|
|
171
171
|
Supported: recurring (cron expr) + one-shot (`runAt`) schedules + webhook triggers (batched events, redelivered after a restart); per-timezone; optional active window for recurring jobs (`activeFrom` / `activeUntil` — "pause until", "send until"); per-job configurable timeout (`timeoutSec`, 60–21600s, default 3600); multi-target fan-out; stable accumulating session per job — its own (id = job id) or a picked existing root session (`sessionId`) — or a fresh session every run (`stateless`); a per-job folder (`workingDir`); run-now (manual trigger); runs resumed after a server restart; admin UI history with the run's session + dispatch results.
|
|
172
172
|
|
|
173
|
-
Not supported: sub-minute schedules below croner's resolution; running in a sub-session (`>` paths — root sessions only); changing a stateful job's agent once its session exists (agent lock);
|
|
173
|
+
Not supported: sub-minute schedules below croner's resolution; running in a sub-session (`>` paths — root sessions only); changing a stateful job's agent once its session exists (agent lock); running cron from the CLI / TUI (server only).
|
|
@@ -71,7 +71,7 @@ Full format in [storage.md](storage.md#session-files-20).
|
|
|
71
71
|
|
|
72
72
|
**Stale anchors are refused (`'archived'`).** `userOrdinal` is positional over the log both sides can see, so it only means anything while client and server agree where the log *starts*. Archiving moves that start forward, and a chat panel open across the compact still counts from the pre-archive top — the same ordinal then maps onto a different turn on the server. The payload now carries the anchor: the client sends `archiveCount`, the archived-segment count its view was opened against. `deleteExchange` counts the committed segments on disk (`countArchiveSegments`, not memory — the segment files are the truth about what was archived) and refuses with `'archived'` only when the two differ; a session with archives can still be deleted from as long as the anchor matches. The WS layer surfaces the refusal as an error frame with `code: 'archived'` (see [ws.md](ws.md)); the admin renders the message verbatim, without the `Error:` prefix it adds to unexpected failures.
|
|
73
73
|
|
|
74
|
-
**Memory/disk sync.** A live session mutates `agent.messages` in place then `saveAgentState`; a cold session's logs are read, edited and rewritten whole (atomic, through the same writers; a 1.x session is migrated first by that read). Rejected with `running` / `compacting` while a turn is in flight (mutating raw mid-turn would corrupt the in-flight conversation). Refresh is push-based: the
|
|
74
|
+
**Memory/disk sync.** A live session mutates `agent.messages` in place then `saveAgentState`; a cold session's logs are read, edited and rewritten whole (atomic, through the same writers; a 1.x session is migrated first by that read). Rejected with `running` / `compacting` while a turn is in flight (mutating raw mid-turn would corrupt the in-flight conversation). Refresh is push-based: the connection subscribed to the session gets `exchange:deleted` (the deleted row ids + the turn's ordinal — not the whole log), other open sessions pick it up via the existing `.halo/sessions/` file watcher. Entry point: WS `exchange:delete` → `handler.ts:handleExchangeDelete` (see [ws.md](ws.md)).
|
|
75
75
|
|
|
76
76
|
## SessionManager
|
|
77
77
|
|
|
@@ -509,7 +509,7 @@ Bytes, not an exchange count, because bytes are the actual problem — file size
|
|
|
509
509
|
|
|
510
510
|
Segments are immutable. Deletion is filesystem-driven: deleting a session lists its directory and removes every file of the session (`ui.jsonl`, `raw.jsonl`, `ui.arch.*`, leftover `.tmp.*`, 1.x `.json` / `arch.*.json.gz`) rather than trusting any count or in-memory map, so crash-orphaned segments go too.
|
|
511
511
|
|
|
512
|
-
**Read side (scroll-up loading).** The admin loads a session's whole active UI log and fetches segments only when the user scrolls to the top: `GET /api/sessions/logs/:id/archive/:n?projectId=` returns `{ messages }` for one whole segment, gunzipped server-side (`:id` is the encoded full id, so a sub-session's segments are reachable too). The anchor is `archiveCount`, delivered on the `state:snapshot` of **subscribe / reattach only** (one segment count per session open); per-turn snapshots
|
|
512
|
+
**Read side (scroll-up loading).** The admin loads a session's whole active UI log and fetches segments only when the user scrolls to the top: `GET /api/sessions/logs/:id/archive/:n?projectId=` returns `{ messages }` for one whole segment, gunzipped server-side (`:id` is the encoded full id, so a sub-session's segments are reachable too). The anchor is `archiveCount`, delivered on the `state:snapshot` of **subscribe / reattach only** (one segment count per session open); per-turn snapshots deliberately omit it (`exchange:delete` answers with `exchange:deleted`, no snapshot). The client walks `archiveCount` → 1 and stops at "no earlier messages"; pulled segments are cached (immutable, so never re-requested) and a failed fetch keeps the cursor so the same segment can be retried.
|
|
513
513
|
|
|
514
514
|
**The anchor moves up, never down.** `noteArchiveAnchor` no-ops for an equal or lower count (keeping per-turn snapshots — which omit the field, read as 0 — and ordinary re-subscribes idempotent), but a **higher** count re-anchors. Needed because a turn that ends while the session is open can archive a new segment, and the next subscribe/reattach snapshot both carries the higher count *and* replaces the view with the shrunken active log: with a permanently pinned anchor those just-archived turns were in neither place — gone from the view and below a cursor counting down from the old anchor — showing a hole until the user reopened the session. Re-anchoring restarts the walk instead of extending it (prepends are oldest-first, so a newer segment can't splice onto an already-pulled tail); the re-pull costs no requests because `segmentCache` still holds them. Archived history renders collapsed and **read-only** — no Delete button, which matches `deleteExchange` refusing archived logs.
|
|
515
515
|
|
|
@@ -9,6 +9,7 @@ File: `packages/server/src/ws/handler.ts`
|
|
|
9
9
|
- Endpoint: `ws://localhost:9527/ws`
|
|
10
10
|
- Auth: `verifyClient` callback validates the JWT cookie
|
|
11
11
|
- All messages are JSON-encoded
|
|
12
|
+
- Compression: permessage-deflate (`perMessageDeflate: { zlibDeflateOptions: { level: 1 }, threshold: 1024 }` in `index.ts`) — frames ≥ 1 KB are compressed when the client offers the extension (browsers do), so a multi-MB subscribe snapshot ships ~4× smaller; stream deltas stay raw. Context takeover is on (~300 KB of zlib state per connection, allocated on the first compressed frame). Only the admin `/ws` server; the node / transcribe / stt / jupyter upgrade routes don't compress
|
|
12
13
|
|
|
13
14
|
### Server-side keepalive
|
|
14
15
|
|
|
@@ -66,7 +67,7 @@ Both directions are typed in `packages/core/src/protocol/ws-frames.ts` (`WsClien
|
|
|
66
67
|
| `chat:stop` | Hard-abort the current generation (ends the turn, no re-run) → `stopUserSession`. During a manual `/compact` (compacting, no turn running) it cancels the compact instead (`cancelCompact`, history rolls back). During an auto-compact — which runs inside a turn and is not cancellable — the abort takes effect once the compact finishes. |
|
|
67
68
|
| `chat:interrupt` | Interrupt the in-flight turn now (aborts a command mid-run); the server then folds any queued messages into one follow-up turn → `interruptSession`. Admin chat esc maps to this. Same compact semantics as `chat:stop`: a manual `/compact` with no turn running is cancelled; an auto-compact finishes first, then the interrupt lands. |
|
|
68
69
|
| `session:delete` | Delete session files + cascade-delete descendants in SQLite (handled inline), then release this connection's listener for it (no save — the session is gone) |
|
|
69
|
-
| `exchange:delete` | Delete one exchange (a user turn + its responses): **soft-delete** in the UI log (`deleted: true` markers, kept visible/greyed) + **physical-delete** the turn's copy from `rawMessages` (LLM context; located by the row's `stamp` — a row from before stamps by its text, among old rows and turns only) → `deleteExchange`. Fields: `userOrdinal` (0-based index among *main-conversation* user turns, i.e. excluding `taskId` sub-agent messages — matches the admin's `isMainConversationMessage` count), `archiveCount` (the archived-segment count the client's view was opened against — its archive anchor, from the `state:snapshot` it got on subscribe), optional `sessionId` / `projectId`. Rejected (→ `error`) while the session is running or compacting. The server also compares `archiveCount` against the archive segments on disk (`countArchiveSegments`); only a **mismatch** is refused, with `code: 'archived'` ("… archived history since it was opened — reopen it to delete individual turns") — a session with archives can still be deleted from as long as the anchor matches. A turn after the last compaction whose raw copy can't be located (a stamped row whose block raw doesn't hold — never a text fallback; an unstamped row merged with others, or its text not found), a stamp shared by two rows or two raw blocks, or a summary row is refused with `code: 'raw_unmatched'`, nothing changed. See [session.md](session.md#exchange-deletion-soft-ui--hard-raw). |
|
|
70
|
+
| `exchange:delete` | Delete one exchange (a user turn + its responses): **soft-delete** in the UI log (`deleted: true` markers, kept visible/greyed) + **physical-delete** the turn's copy from `rawMessages` (LLM context; located by the row's `stamp` — a row from before stamps by its text, among old rows and turns only) → `deleteExchange`. Fields: `userOrdinal` (0-based index among *main-conversation* user turns, i.e. excluding `taskId` sub-agent messages — matches the admin's `isMainConversationMessage` count), `archiveCount` (the archived-segment count the client's view was opened against — its archive anchor, from the `state:snapshot` it got on subscribe), optional `sessionId` / `projectId`. On success the server answers this connection — when it is subscribed to the session — with `exchange:deleted` (ids only, no snapshot). Rejected (→ `error`) while the session is running or compacting. The server also compares `archiveCount` against the archive segments on disk (`countArchiveSegments`); only a **mismatch** is refused, with `code: 'archived'` ("… archived history since it was opened — reopen it to delete individual turns") — a session with archives can still be deleted from as long as the anchor matches. A turn after the last compaction whose raw copy can't be located (a stamped row whose block raw doesn't hold — never a text fallback; an unstamped row merged with others, or its text not found), a stamp shared by two rows or two raw blocks, or a summary row is refused with `code: 'raw_unmatched'`, nothing changed. See [session.md](session.md#exchange-deletion-soft-ui--hard-raw). |
|
|
70
71
|
| `command:<name>` | Route through shared `dispatchCommand` (see [command.md](command.md)); `/session compact` handled inline for UI callbacks |
|
|
71
72
|
| `terminal:start` | Spawn a new PTY in `workspacePath`, named `name` (the tab name every page shows); pushed to the workspace's other subscribers as `terminal:opened` |
|
|
72
73
|
| `terminal:input` | Send keystrokes; the sender becomes the PTY's size driver (PTY switches to the size it last reported). `terminalId` is **required** — an id-less frame is logged and dropped (it used to fall back to the first entry of the process-global terminal map, which could write into a PTY of another workspace) |
|
|
@@ -127,7 +128,8 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
|
|
|
127
128
|
|
|
128
129
|
| Type | Source | Purpose |
|
|
129
130
|
|---|---|---|
|
|
130
|
-
| `state:snapshot` | handler.ts on connect | Initial state (agents, messages, sessionId). On **subscribe / reattach only** it also carries `archiveCount` — the number of committed UI-log archive segments, which the admin's scroll-up loader counts down from (see [Archive anchor](#archive-anchor-in-statesnapshot)) — and `accessLevel` (`'workspace' | 'readonly' | null`, null = full), which seeds the admin's access-level selector. Subscribe includes `accessLevel` only when the session already exists; for a not-yet-created session it's omitted, and the admin leaves the selector alone when the field is absent (so a level picked before the first send isn't reset) |
|
|
131
|
+
| `state:snapshot` | handler.ts on connect | Initial state (agents, messages, sessionId). On **subscribe / reattach only** it also carries `archiveCount` — the number of committed UI-log archive segments, which the admin's scroll-up loader counts down from (see [Archive anchor](#archive-anchor-in-statesnapshot)) — and `accessLevel` (`'workspace' | 'readonly' | null`, null = full), which seeds the admin's access-level selector. Subscribe includes `accessLevel` only when the session already exists; for a not-yet-created session it's omitted, and the admin leaves the selector alone when the field is absent (so a level picked before the first send isn't reset). The subscribe / reattach rows leave out the log's `tool_call` / `tool_result` rows (`adminSnapshotRows`) — the chat renders each tool call from the assistant row's `contentBlocks`; usage / context rows stay. The on-disk UI log and the other channels keep them. The admin reuses the objects of unchanged rows on replace (`reconcileMessages`), so memoized rows skip re-render |
|
|
132
|
+
| `exchange:deleted` | `handleExchangeDelete` (this client, when subscribed to the session) | An `exchange:delete` landed — `{sessionId, deletedIds, userOrdinal, userRows}`: `deletedIds` is every soft-deleted row id of the active log (idempotent), `userOrdinal` the turn just deleted, `userRows` the log's user rows without `taskId` (deleted ones included — the count the ordinal runs over). The admin flags those rows plus the `userOrdinal` turn's span (rows this tab created live carry client ids the server log doesn't know) — no log replace. When the tab's own count differs from `userRows` (a turn another connection sent with `localEcho`, a `sendFailed` bubble) it flags the ids only and re-sends `subscribe`, whose snapshot corrects the log |
|
|
131
133
|
| `chat:ack` | `handleChat` | Chat with `clientMsgId` is persisted in the session log — releases the client's pending-ack entry (see [Chat delivery](#chat-delivery-ack--resend--dedup)) |
|
|
132
134
|
| `__pong__` | `__ping__` handler | Reply to the client's application-level liveness probe |
|
|
133
135
|
| `listener:released` | `reclaimIfAbandoned` (this client only) | One of this connection's event listeners was reclaimed (silent >3 min / CLOSED) — `{sessionId}` (never null), one frame per released session. Client must re-`subscribe` to reattach; the admin does so only for the tab on screen. See [Abandoned-listener reclaim](#abandoned-listener-reclaim-and-the-__ping__-contract). |
|
|
@@ -141,7 +143,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
|
|
|
141
143
|
| `chat:stopped` | `chat:stop` / `chat:interrupt` handlers (this client only) | Stop/interrupt acknowledged — `{sessionId}` |
|
|
142
144
|
| `session:compacted` | compact handler | Compaction complete — `{message?, contextTokens, sessionId}` |
|
|
143
145
|
| `compact:started` / `compact:summarizing` / `compact:done` | compact handler; `ws/event-processor.ts` | Compaction progress, `{sessionId}`. An auto-compact whose LLM summary failed falls back to a local compact and closes with a `system` event carrying `compactEnd`, which also emits `compact:done` (the success path closes via `compacted`). |
|
|
144
|
-
| `cron:job_changed` / `cron:run_changed` | `cron/runner.ts` + `routes/cron.ts` (broadcast) | Cron job/run state changed — the
|
|
146
|
+
| `cron:job_changed` / `cron:run_changed` | `cron/runner.ts` + `routes/cron.ts` (broadcast) | Cron job/run state changed — the Triggers (cron) tab re-fetches instead of polling. `cron:job_changed`: `{jobId, kind?}` (`created` / `updated` / `deleted` / `reconciled` / `missed`; a run's finalize sends `lastRunStatus` + `lastRunAt` instead), plus — whenever the job still exists, except `missed` — `workingDir` (workspace-relative, `null` = root), `stateless` (boolean) and `sessionAgentId` (agent of the job's existing session, `null` when stateless / not created / unreadable; the job's agent is locked to it). `cron:run_changed`: `{jobId, runId, status}`; `status: 'deleted'` = that run row was withdrawn (a webhook fire's re-check found the session taken by another owner) — clients just re-fetch the list. See [cron.md](cron.md). |
|
|
145
147
|
| `evolution:run_changed` / `evolution:apply_changed` | `evolution/ticker.ts` + `routes/evolution.ts` (broadcast) | Evolution run/apply state changed — drives the Evolution tab. Includes the L2/L3 auto path (`running → approved` plus the new `approved_by='auto'` apply, pushed by the ticker's diff) and `rolled_back` (pushed by the rollback route). See [evolution.md](evolution.md). |
|
|
146
148
|
| `models:changed` | `models/watcher.ts` `reloadAndBroadcast` (broadcast to all clients) | `~/.halo/global/models.d/` changed (hub provider yamls installed by `halo models install` / `/extension models`, or removed by hand) and the merged registry differs from the last one — the server has already dropped its registry cache. No payload: the admin bumps its models bus, and the Agents panel / agent form, the composer's image-capability check and the Settings page re-fetch `/agent-configs/models` / the settings schema. Also bumped on WS reconnect (a frame lost while down). Global, no workspace marker. See [storage.md](storage.md#model-registry-format). |
|
|
147
149
|
| `skills:changed` | `ws/skills-changed.ts` `notifySkillsChanged` (REST `POST` / `DELETE /skills`, `PATCH /skills/:id/toggle`, `/skill disable` · `enable` · `delete`) · `ws/watcher-pool.ts` (the workspace watcher saw `.halo/skills/**` change) | A skill was created / deleted / toggled, or a workspace skill folder changed on disk (an agent's `file_write`, a hand edit). No payload: the admin bumps its skill bus, and the chat slash-command popup, the Skills page and the agent form's skill picker re-fetch. Scoped on the server: a workspace skill or a toggle (the disable flag lives in that workspace's db) goes via `broadcastToWorkspace`, a global skill via `broadcast`. Only real changes emit: a no-op mutation (409 / 404 / already disabled) sends nothing, and the watcher path compares a per-workspace key (folder ids + each SKILL.md's frontmatter), so body / resource edits and the echo of a mutation that already notified stay silent. The watcher debounce is 300 ms on top of the file watcher's own 300 ms flush. `~/.halo/global/skills/` has no watcher, so a hand edit there shows on the Skills page's window-focus refetch. Also bumped on WS reconnect while the Skills page is open. |
|
|
@@ -155,7 +157,7 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
|
|
|
155
157
|
|
|
156
158
|
### Archive anchor in `state:snapshot`
|
|
157
159
|
|
|
158
|
-
`archiveCount` rides only the snapshots sent on **subscribe** and **reattach** — one segment count per session open. The per-turn snapshots
|
|
160
|
+
`archiveCount` rides only the snapshots sent on **subscribe** and **reattach** — one segment count per session open. The per-turn snapshots deliberately omit it (and `exchange:delete` answers with `exchange:deleted`, no snapshot): the client's "load older" cursor is an anchor pinned at the moment the session was opened, and a segment written mid-session (a compact while the tab is open) must not move it. The client then requests segments over HTTP (`GET /api/sessions/logs/:id/archive/:n`), not WS — one whole segment per call, cached client-side because segments are immutable. See [session.md](session.md#ui-log-archiving).
|
|
159
161
|
|
|
160
162
|
The same anchor is what the client sends back as `archiveCount` on `exchange:delete`, so the server can tell a stale ordinal from a live one.
|
|
161
163
|
|
|
@@ -229,7 +231,7 @@ Teardown lives in one function (`cleanupConnection`) shared by the socket's `clo
|
|
|
229
231
|
Client reconnects and sends `subscribe`:
|
|
230
232
|
1. Server detects the detached session
|
|
231
233
|
2. Loads UIState from SessionManager (via `getUIState`) and builds a save snapshot
|
|
232
|
-
3. Sends `state:snapshot` — messages from `createSaveSnapshot(state)`, or the settled `messageLog` only when the session is still running (see step 6) — plus `archiveCount`, re-pinning the scroll-up anchor for the reopened log
|
|
234
|
+
3. Sends `state:snapshot` — messages from `createSaveSnapshot(state)`, or the settled `messageLog` only when the session is still running (see step 6), minus the `tool_call` / `tool_result` rows — plus `archiveCount`, re-pinning the scroll-up anchor for the reopened log
|
|
233
235
|
4. Replays buffered `pendingEvents`
|
|
234
236
|
5. Re-attach the event listener — live streaming continues seamlessly
|
|
235
237
|
6. If the session is still running, synthesizes the in-progress turn as an **authoritative replay**: every synthesized message carries `replay: true` — a `chat:followup`, then the turn's `turnContentBlocks` in order (`chat:thinking` / `chat:stream` / `agent:tool_call` + `agent:tool_result` if completed), each with the session's real `agentName` and the block's real `turnId` so usage badges / turn grouping survive the reconnect. The running-session snapshot in step 3 sends the settled `messageLog` only (no `createSaveSnapshot` temp in-flight message — the replay carries that turn instead).
|
package/bundled-docs/dev/api.md
CHANGED
|
@@ -228,10 +228,10 @@ File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `c
|
|
|
228
228
|
| Method | Path | Purpose |
|
|
229
229
|
|---|---|---|
|
|
230
230
|
| GET | `/api/cron/jobs?limit=&before=` | Cursor-paginated newest-first by `createdAt`. Default `limit` 20, max 100; `before` is a `createdAt` cursor. Returns `{jobs, hasMore, nextCursor}`. Each job has `targets` decoded, a live `nextRunAt` (computed from schedule/runAt, inside the active window), `sessionId` (`null` = runs in the job's own session), `effectiveSessionId` (the session the run actually uses: `sessionId`, else the job's own — same id as the job, `cron-<id>` for a custom id without that prefix; older jobs were pinned to their original `cron-cron-…` session by a cron.db migration), `activeFrom` / `activeUntil` (`null` = unbounded), `workingDir` (workspace-relative folder the run focuses on; `null` = the workspace root), `stateless` (boolean; `true` = a fresh session every run), and `sessionAgentId` (the agent of the effective session once it exists — the job's agent is locked to it; `null` for a stateless job, a session not created yet, or a session that can't be read), `webhookSecret` (`null` unless a webhook job) and `webhookPath` (`/cron-hooks/<id>`, relative — prefix the public origin; `null` unless a webhook job) |
|
|
231
|
-
| POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt
|
|
232
|
-
| PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt` and enforces trigger-mode exclusivity on the **merged** row: both in one body → 400 `schedule and runAt are mutually exclusive`; supplying one side is a mode switch that implicitly clears the other; a body that clears the active side without supplying a replacement → 400 `schedule, runAt or webhook required`. `webhook: true` switches to webhook mode (an existing secret is kept; with `schedule` / `runAt` in the same body → 400); `webhook: false`, or a `schedule` / `runAt` alone, switches away and drops the secret. `runAt` must be a finite future epoch-ms number or `null`. `timeoutSec` follows the same partial-body contract: omitted = unchanged, `null` = clear back to the default, a number is range-validated (60–21600) same as create. `timezone` likewise: omitted = unchanged, `null` / `''` = clear back to the host timezone, anything else is validated same as create (same two 400s). `sessionId` likewise: omitted = unchanged, `null` / `''` = back to the job's own session (an older job pinned to `cron-cron-…` then starts a fresh one), a string is validated same as create. `activeFrom` / `activeUntil` likewise (omitted = unchanged, `null` = clear); the window rules are checked on the **merged** row, so switching to `runAt` while a window is stored needs both bounds cleared in the same body, and a newly supplied `activeUntil` must be in the future (a stored past one doesn't block editing other fields). `workingDir` likewise (omitted = unchanged, `null` / `''` = back to the root); a stored folder is re-checked when `workspacePath` changes. `stateless` and the `sessionId` exclusivity are checked on the **merged** row. The agent-lock / read-error 400s (see POST) apply only when the body changes the stored agent, session, `stateless` or `workspacePath` — so an unrelated edit such as `{enabled:false}` always succeeds, even for a job whose session already runs another agent. Flips schedule/unschedule on `enabled`. Returns `{ok}` |
|
|
231
|
+
| POST | `/api/cron/jobs` | Create a job. Body: `{label?, workspacePath, agentId, userPrompt?, schedule, runAt?, webhook?, timezone?, timeoutSec?, sessionId?, workingDir?, stateless?, targets?, enabled?}`. Exactly one of `schedule` (5-field cron), `runAt` (epoch ms, future) or `webhook: true` is required (none → 400 `schedule, runAt or webhook required`; two → 400 `… mutually exclusive`). `webhook` (boolean, else 400) makes it a webhook-triggered job: the server generates its secret; a webhook job takes no active window. `userPrompt` is required (else 400 `userPrompt required`) except on a webhook job, where omitted / `''` is stored as `''` and each run opens with its events alone. It may pick a `sessionId` like any stateful job: its events wait while that session is owed to another owner (relay / A2A / an invocation / another job's run) and run once it is released. `timeoutSec` must be an integer 60–21600 (else 400); unset = runner default 3600. `timezone` is an IANA name (e.g. `Asia/Shanghai`); unset / `null` / `''` = the host timezone (see `/api/cron/meta`); a non-string → 400 `timezone must be a string`, a zone `Intl.DateTimeFormat` rejects → 400 `invalid timezone: <value>`. `sessionId` picks an existing (or to-be-created) **root** session of `workspacePath` for the runs to use — `[A-Za-z0-9_:-]{1,200}`, no `>` sub-session path (else 400); unset / `null` / `''` = the job's own session (id = the job id). A picked session that already exists keeps its own access level, and its agent must equal `agentId` (agent lock, below); see [design/cron.md](../design/cron.md#execution-runjob) for the shared-session guards. `activeFrom` / `activeUntil` (epoch ms or `null`, recurring only): the schedule fires only inside `[activeFrom, activeUntil)`; with `runAt` → 400; `activeUntil` must be in the future and after `activeFrom` (else 400); `activeFrom` may be in the past. `workingDir` (workspace-relative or absolute; unset / `null` / `''` = the root) must be an existing directory inside `workspacePath` (else 400 `workingDir: …`); stored workspace-relative. `stateless` (boolean, else 400) runs every fire in a fresh session; with `sessionId` → 400 `stateless (new session every run) and sessionId are mutually exclusive`. Agent lock: when the effective session already exists and runs another agent → 400 `session <id> already runs agent <x>; …`; a session db that can't be read → 400 `could not read session <id> from <ws>: …`. Returns `{ok, id}` |
|
|
232
|
+
| PUT | `/api/cron/jobs/:id` | Partial update over the same fields. Re-validates `schedule` / `runAt` and enforces trigger-mode exclusivity on the **merged** row: both in one body → 400 `schedule and runAt are mutually exclusive`; supplying one side is a mode switch that implicitly clears the other; a body that clears the active side without supplying a replacement → 400 `schedule, runAt or webhook required`. `webhook: true` switches to webhook mode (an existing secret is kept; with `schedule` / `runAt` in the same body → 400); `webhook: false`, or a `schedule` / `runAt` alone, switches away and drops the secret. Only a webhook job may have an empty `userPrompt`: a body that empties a timed job's prompt, or switches an empty-prompt webhook job to `schedule` / `runAt` without a non-empty `userPrompt`, → 400 `userPrompt required (only a webhook job may leave it empty)`. `runAt` must be a finite future epoch-ms number or `null`. `timeoutSec` follows the same partial-body contract: omitted = unchanged, `null` = clear back to the default, a number is range-validated (60–21600) same as create. `timezone` likewise: omitted = unchanged, `null` / `''` = clear back to the host timezone, anything else is validated same as create (same two 400s). `sessionId` likewise: omitted = unchanged, `null` / `''` = back to the job's own session (an older job pinned to `cron-cron-…` then starts a fresh one), a string is validated same as create. `activeFrom` / `activeUntil` likewise (omitted = unchanged, `null` = clear); the window rules are checked on the **merged** row, so switching to `runAt` while a window is stored needs both bounds cleared in the same body, and a newly supplied `activeUntil` must be in the future (a stored past one doesn't block editing other fields). `workingDir` likewise (omitted = unchanged, `null` / `''` = back to the root); a stored folder is re-checked when `workspacePath` changes. `stateless` and the `sessionId` exclusivity are checked on the **merged** row. The agent-lock / read-error 400s (see POST) apply only when the body changes the stored agent, session, `stateless` or `workspacePath` — so an unrelated edit such as `{enabled:false}` always succeeds, even for a job whose session already runs another agent. Flips schedule/unschedule on `enabled`. Returns `{ok}` |
|
|
233
233
|
| DELETE | `/api/cron/jobs/:id` | Unschedule, delete each stateless run's own session (except running / user-continued ones) and the job's `cron_runs` rows, then delete the job. A run still in progress keeps its row and session until it finalizes, then both go ([cron.md → Retention](../design/cron.md#retention)). Returns `{ok}` |
|
|
234
|
-
| POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget; on a webhook job the prompt runs alone, no events). Returns `{ok}` |
|
|
234
|
+
| POST | `/api/cron/jobs/:id/run-now` | Fire the job immediately (fire-and-forget; on a webhook job the prompt runs alone, no events — with an empty prompt the run is recorded `skipped`). Returns `{ok}` |
|
|
235
235
|
| POST | `/api/cron/jobs/:id/webhook-secret` | Rotate a webhook job's secret — the old one stops working at once. 404 unknown job, 400 `not a webhook job`. Returns `{ok, webhookSecret}` |
|
|
236
236
|
| POST | `/api/cron/reload` | Re-read every job from db and rebuild schedules. Returns `{ok}` |
|
|
237
237
|
|
|
@@ -1105,6 +1105,7 @@ It does not remove session files. The full delete (rows + JSON + archive segment
|
|
|
1105
1105
|
| `chat:queued` | `reason: 'compact'`, `message`, `sessionId` | Message queued behind a compact |
|
|
1106
1106
|
| `chat:ack` | `clientMsgId` | Chat appended to the session log (or re-acked on resend) |
|
|
1107
1107
|
| `listener:released` | `sessionId` | Event listener reclaimed — re-send `subscribe` (see above) |
|
|
1108
|
+
| `exchange:deleted` | `sessionId`, `deletedIds`, `userOrdinal`, `userRows` | An `exchange:delete` this connection sent landed: ids of every soft-deleted row of the active log, the ordinal of the turn just deleted, and the log's user-turn count (without `taskId`) that ordinal runs over (the sender's own reply only — no full `state:snapshot` follows) |
|
|
1108
1109
|
| `skills:changed` | — | A skill was created / deleted / toggled, or a workspace skill folder changed on disk — re-fetch skill lists and `/api/commands`. Workspace-scoped changes reach only that workspace's sockets (see [Skills](#skills)) |
|
|
1109
1110
|
| `nodes:changed` | `online: [{accountId, root \| null, shell, os, since}]` | A `halo node` connected / disconnected — full snapshot of online machines (global broadcast; `root: null` = whole machine). See [Linked machines](#linked-machines-halo-node) |
|
|
1110
1111
|
|
|
@@ -113,7 +113,7 @@ Top-right `Test` button:
|
|
|
113
113
|
|
|
114
114
|
Better than the old built-in test chat — it has full workspace tools + session persistence.
|
|
115
115
|
|
|
116
|
-
Internal agents (`internal: true`, e.g. self-evolution agents) have no Test button and never appear in the chat agent selector, the
|
|
116
|
+
Internal agents (`internal: true`, e.g. self-evolution agents) have no Test button and never appear in the chat agent selector, the Triggers (cron) agent picker or `/session new`'s default pick — they're delegated to by other agents, never driven directly. They remain editable in the management sidebar's collapsed **Internal** group.
|
|
117
117
|
|
|
118
118
|
## Delete
|
|
119
119
|
|
|
@@ -93,6 +93,6 @@ API: `GET/PUT /api/agent-configs/:id/md/:fileType`, `GET /api/agent-configs/:id/
|
|
|
93
93
|
- **Not rendered for internal agents** (`internal: true`) — they're delegated to by other agents, never driven directly
|
|
94
94
|
|
|
95
95
|
### Internal agents
|
|
96
|
-
Agents flagged `internal: true` in agent.yaml (e.g. `__evo_agent__`, `__apply_agent__`, `__score__`) are platform tooling. They are hidden from every user-facing surface: the delegation roster, `/session new` default pick, the chat agent selector, the
|
|
96
|
+
Agents flagged `internal: true` in agent.yaml (e.g. `__evo_agent__`, `__apply_agent__`, `__score__`) are platform tooling. They are hidden from every user-facing surface: the delegation roster, `/session new` default pick, the chat agent selector, the Triggers (cron) job agent picker and `halo cli`'s agent list (`halo agents`) — and have no Test button. They stay editable in the management sidebar's collapsed **Internal** group.
|
|
97
97
|
|
|
98
98
|
Replaces the old built-in test chat panel, giving a more realistic environment (full workspace tools + session persistence).
|
|
@@ -37,12 +37,12 @@ The Bottom Panel (Chat + Terminal) can also be **floated** into a draggable wind
|
|
|
37
37
|
| Zap | Skills | Skill list | SkillsMain (full height) | — |
|
|
38
38
|
| MessageCircle | Channels | Channel list | Channel config editor | — |
|
|
39
39
|
| Sparkles | Evolution | Run list + status / auto / observation filters | Evolution main (run/apply review, auto-apply verdict, rollback, observation → GitHub issue) | — |
|
|
40
|
-
| Clock |
|
|
40
|
+
| Clock | Triggers (触发器) | Job list | Cron job detail / form + run audit | — |
|
|
41
41
|
| Settings2 | Settings | — | Settings panel | — |
|
|
42
42
|
|
|
43
|
-
The Source Control entry is hidden in workspaces that are not git repos.
|
|
43
|
+
The Source Control entry is hidden in workspaces that are not git repos. The Triggers tab is the UI name for cron jobs (schedule, one-shot and webhook); code, API, routes and the `cron` skill keep the `cron` name.
|
|
44
44
|
|
|
45
|
-
**Extension entries.** An installed [canvas extension](../design/canvas-extensions.md#activity-bar-entries) whose manifest declares `activity` adds one button after
|
|
45
|
+
**Extension entries.** An installed [canvas extension](../design/canvas-extensions.md#activity-bar-entries) whose manifest declares `activity` adds one button after Triggers (several sort by the manifest's `order`, then id; the built-in tabs never move). It uses the extension's icon (tinted like the built-in icons; a puzzle piece when it has none) and its title / description in the hover drawer, in the admin's language. Clicking it shows the extension's page full-width in the main area with no sidebar, like Agents / Settings; leaving the tab closes the page (anything it was playing or refreshing stops). The last-used page is restored after a reload; if that extension has been removed, or doesn't run on this device, the workspace opens on Explorer instead. With no such extension installed, the bar is unchanged.
|
|
46
46
|
|
|
47
47
|
**Short windows.** When the window is too short for every view button, the upper group (built-in views + extension entries) scrolls (no visible scrollbar); Settings and the quick-toggles entry stay pinned at the bottom.
|
|
48
48
|
|