@turmind/halo 1.6.0 → 1.6.2

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.
Files changed (59) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/58.d26b1c4f14404226.js +1 -0
  3. package/admin-out/_next/static/chunks/989-ddba0eb72566da23.js +1 -0
  4. package/admin-out/_next/static/chunks/app/page-1d27029caba48956.js +1 -0
  5. package/admin-out/_next/static/chunks/webpack-74f577c1b094f942.js +1 -0
  6. package/admin-out/_next/static/css/3b4dcd0ac6b8eae8.css +1 -0
  7. package/admin-out/index.html +1 -1
  8. package/admin-out/index.txt +5 -5
  9. package/bundled-docs/design/agent.md +6 -4
  10. package/bundled-docs/design/canvas-extensions.md +30 -4
  11. package/bundled-docs/design/cron.md +24 -11
  12. package/bundled-docs/design/prompt-system.md +10 -5
  13. package/bundled-docs/design/session.md +4 -1
  14. package/bundled-docs/design/storage.md +36 -11
  15. package/bundled-docs/design/ws.md +7 -5
  16. package/bundled-docs/dev/add-model-provider.md +17 -11
  17. package/bundled-docs/dev/api.md +25 -12
  18. package/bundled-docs/dev/deploy.md +3 -0
  19. package/bundled-docs/dev/desktop-packaging.md +11 -5
  20. package/bundled-docs/dev/env.md +1 -1
  21. package/bundled-docs/dev/previews.md +26 -0
  22. package/bundled-docs/guide/agents.md +6 -6
  23. package/bundled-docs/guide/cli.md +5 -4
  24. package/bundled-docs/guide/skills.md +12 -6
  25. package/bundled-docs/guide/workspace.md +1 -1
  26. package/bundled-docs/requirements/agent.md +5 -0
  27. package/bundled-docs/requirements/chat.md +9 -5
  28. package/bundled-docs/requirements/command.md +4 -0
  29. package/bundled-docs/requirements/editor.md +20 -6
  30. package/bundled-docs/requirements/session.md +1 -1
  31. package/bundled-docs/requirements/skill.md +15 -9
  32. package/dist/index.js +2332 -1348
  33. package/package.json +1 -1
  34. package/templates/models/anthropic.yaml +109 -13
  35. package/templates/models/aws-bedrock-claude-invoke.yaml +39 -13
  36. package/templates/models/aws-bedrock-mantle.yaml +60 -28
  37. package/templates/models/aws-bedrock-openai.yaml +52 -29
  38. package/templates/models/deepseek.yaml +19 -10
  39. package/templates/models/doubao.yaml +72 -20
  40. package/templates/models/hunyuan.yaml +79 -15
  41. package/templates/models/kimi.yaml +63 -3
  42. package/templates/models/mimo-token-plan-china.yaml +9 -1
  43. package/templates/models/minimax.yaml +9 -3
  44. package/templates/models/openai.yaml +120 -17
  45. package/templates/models/qwen.yaml +103 -6
  46. package/templates/models/zhipu.yaml +9 -1
  47. package/templates/schema.sql +1 -1
  48. package/templates/skills/cron/SKILL.md +40 -3
  49. package/templates/skills/cron/manage-cron.py +140 -15
  50. package/templates/skills/skill/SKILL.md +22 -11
  51. package/templates/skills/workspace/share.md +5 -2
  52. package/templates/skills/workspace/stage.py +51 -14
  53. package/admin-out/_next/static/chunks/220.7566bc89f3d98993.js +0 -1
  54. package/admin-out/_next/static/chunks/989-a7f752480eaaa2dc.js +0 -1
  55. package/admin-out/_next/static/chunks/app/page-8173112f256bc483.js +0 -1
  56. package/admin-out/_next/static/chunks/webpack-dc73047e0279979b.js +0 -1
  57. package/admin-out/_next/static/css/981c9e5610ce535a.css +0 -1
  58. /package/admin-out/_next/static/{_F_Fkyh9PainUNY82EEjY → VgAe62AuGEiYrF4JQ7WQe}/_buildManifest.js +0 -0
  59. /package/admin-out/_next/static/{_F_Fkyh9PainUNY82EEjY → VgAe62AuGEiYrF4JQ7WQe}/_ssgManifest.js +0 -0
@@ -96,7 +96,10 @@ def parse_targets(raw: str) -> list[dict]:
96
96
  if not raw:
97
97
  return []
98
98
  if raw.startswith('['):
99
- return json.loads(raw)
99
+ try:
100
+ return json.loads(raw)
101
+ except ValueError as err:
102
+ die(f'invalid --targets JSON: {err}')
100
103
  out = []
101
104
  for token in raw.split(','):
102
105
  token = token.strip()
@@ -127,7 +130,8 @@ def parse_timeout_sec(raw: str | None) -> int | None:
127
130
  return v
128
131
 
129
132
 
130
- SESSION_ID_RE = re.compile(r'^[A-Za-z0-9_:-]{1,200}$')
133
+ # Always fullmatch: with `$`, re.match also accepts one trailing "\n".
134
+ SESSION_ID_RE = re.compile(r'[A-Za-z0-9_:-]{1,200}')
131
135
 
132
136
 
133
137
  def parse_session(raw: str | None) -> str | None:
@@ -137,11 +141,75 @@ def parse_session(raw: str | None) -> str | None:
137
141
  if raw is None or not raw.strip():
138
142
  return None
139
143
  v = raw.strip()
140
- if not SESSION_ID_RE.match(v):
144
+ if not SESSION_ID_RE.fullmatch(v):
141
145
  die(f'invalid --session "{raw}": expected a root session id (letters, digits, _ : - only; no ">" sub-session path)')
142
146
  return v
143
147
 
144
148
 
149
+ def parse_dir(workspace: str, raw: str | None) -> str | None:
150
+ """Validate --dir against the job's workspace (same rule as the admin REST
151
+ routes / `halo cli --dir`): workspace-relative or absolute, inside the
152
+ workspace, an existing directory. Returns the workspace-relative path;
153
+ empty / the workspace root = None (= the root)."""
154
+ if raw is None or not raw.strip():
155
+ return None
156
+ v = raw.strip()
157
+ root = os.path.abspath(workspace)
158
+ resolved = os.path.abspath(v if os.path.isabs(v) else os.path.join(root, v))
159
+ if resolved != root and not resolved.startswith(root + os.sep):
160
+ die(f'invalid --dir "{raw}": outside the workspace {root}')
161
+ if not os.path.exists(resolved):
162
+ die(f'invalid --dir "{raw}": does not exist')
163
+ if not os.path.isdir(resolved):
164
+ die(f'invalid --dir "{raw}": not a directory')
165
+ rel = os.path.relpath(resolved, root)
166
+ return None if rel == '.' else rel
167
+
168
+
169
+ def effective_session_id(job_id: str, session_id: str | None) -> str:
170
+ """runner.ts effectiveSessionId: the picked session, else the job's own
171
+ (the job id; `cron-<id>` for an id without that prefix)."""
172
+ return session_id or (job_id if job_id.startswith('cron-') else f'cron-{job_id}')
173
+
174
+
175
+ def session_agent(workspace: str, session_id: str) -> str | None:
176
+ """Agent of an existing session in the workspace db, else None."""
177
+ db = Path(workspace) / '.halo' / 'halo.db'
178
+ if not db.exists():
179
+ return None
180
+ try:
181
+ # as_uri() percent-encodes `?` / `#` / `%` in the path.
182
+ conn = sqlite3.connect(db.resolve().as_uri() + '?mode=ro', uri=True)
183
+ row = conn.execute('SELECT agent_id FROM agent_sessions WHERE id = ?', (session_id,)).fetchone()
184
+ conn.close()
185
+ except sqlite3.Error:
186
+ return None
187
+ return row[0] if row else None
188
+
189
+
190
+ def check_session_rules(job: dict):
191
+ """Write-point rules on the job's final state (same as the REST routes):
192
+ stateless excludes a picked session, and a stateful job's agent is fixed
193
+ once its session exists."""
194
+ if job.get('stateless') and job.get('session_id'):
195
+ die('--stateless (new session every run) and --session are mutually exclusive')
196
+ if job.get('stateless'):
197
+ return
198
+ sid = effective_session_id(job['id'], job.get('session_id'))
199
+ agent = session_agent(job['workspace_path'], sid)
200
+ if agent is not None and agent != job['agent_id']:
201
+ die(f'session {sid} already runs agent {agent}; delete that session or turn on '
202
+ f'"new session every run" (--stateless) to change the agent')
203
+
204
+
205
+ def require_v5_columns(conn: sqlite3.Connection):
206
+ """cron.db gains working_dir/stateless via the server's boot migration."""
207
+ cols = {r[1] for r in conn.execute('PRAGMA table_info(cron_jobs)').fetchall()}
208
+ if 'working_dir' not in cols or 'stateless' not in cols:
209
+ die('cron.db has no working_dir/stateless columns yet — restart the halo server '
210
+ 'once so it migrates cron.db, then retry')
211
+
212
+
145
213
  def parse_run_at(raw: str | None) -> int | None:
146
214
  """Accepts ISO-8601 (`2026-05-24T09:00:00`) or a unix-ms integer; returns
147
215
  epoch ms or None. Local time without offset is parsed as host TZ —
@@ -254,6 +322,11 @@ def row_to_job(row: sqlite3.Row) -> dict:
254
322
  except Exception:
255
323
  j['targets'] = []
256
324
  j['enabled'] = bool(j['enabled'])
325
+ # Folder + stateless (pre-migration dbs: unset) and the session this job
326
+ # runs in — `null` for stateless (a new one every run).
327
+ j.setdefault('working_dir', None)
328
+ j['stateless'] = bool(j.get('stateless') or 0)
329
+ j['effective_session_id'] = None if j['stateless'] else effective_session_id(j['id'], j.get('session_id'))
257
330
  # Active window: ms (as stored) + ISO in the job's timezone. Pre-migration
258
331
  # dbs have no columns — report them as unset.
259
332
  for k in ('active_from', 'active_until'):
@@ -293,9 +366,11 @@ def cmd_get(args):
293
366
  if not row:
294
367
  die(f'job {args.id} not found', 2)
295
368
  job = row_to_job(row)
369
+ run_cols = {r[1] for r in conn.execute('PRAGMA table_info(cron_runs)').fetchall()}
296
370
  runs = conn.execute(
297
- 'SELECT id, status, started_at, completed_at, exit_code, failure_reason '
298
- 'FROM cron_runs WHERE job_id = ? ORDER BY id DESC LIMIT 5',
371
+ 'SELECT id, status, started_at, completed_at, exit_code, failure_reason'
372
+ + (', session_id' if 'session_id' in run_cols else '') +
373
+ ' FROM cron_runs WHERE job_id = ? ORDER BY id DESC LIMIT 5',
299
374
  (args.id,),
300
375
  ).fetchall()
301
376
  job['recentRuns'] = [dict(r) for r in runs]
@@ -317,11 +392,20 @@ def cmd_create(args):
317
392
  die('--run-at must be in the future')
318
393
  timeout_sec = parse_timeout_sec(args.timeout_sec)
319
394
  session_id = parse_session(args.session)
395
+ working_dir = parse_dir(args.workspace, args.dir)
320
396
  active_from = parse_active_bound('--active-from', args.active_from or '', args.timezone)
321
397
  active_until = parse_active_bound('--active-until', args.active_until or '', args.timezone)
322
398
  validate_active_window(run_at_ms, active_from, active_until, new_until=True)
323
399
 
400
+ # A custom id becomes the job's own session id (`cron-<id>`), so it takes
401
+ # the session-id charset: a `>` would make it a sub-session path.
402
+ if args.id and not SESSION_ID_RE.fullmatch(args.id):
403
+ die(f'invalid --id "{args.id}": letters, digits, _ : - only (it becomes the job\'s session id)')
324
404
  job_id = args.id or gen_job_id()
405
+ check_session_rules({
406
+ 'id': job_id, 'workspace_path': args.workspace, 'agent_id': args.agent or 'default',
407
+ 'session_id': session_id, 'stateless': args.stateless,
408
+ })
325
409
  now = now_ms()
326
410
  targets = parse_targets(args.targets) if args.targets else []
327
411
 
@@ -346,8 +430,12 @@ def cmd_create(args):
346
430
  'created_at': now,
347
431
  'updated_at': now,
348
432
  }
349
- # Only name the window columns when used, so jobs without a window can
350
- # still be created on a cron.db the server hasn't migrated yet.
433
+ # Only name the newer columns when used, so plain jobs can still be
434
+ # created on a cron.db the server hasn't migrated yet.
435
+ if working_dir is not None or args.stateless:
436
+ require_v5_columns(conn)
437
+ values['working_dir'] = working_dir
438
+ values['stateless'] = 1 if args.stateless else 0
351
439
  if active_from is not None or active_until is not None:
352
440
  require_active_columns(conn)
353
441
  values['active_from'] = active_from
@@ -371,12 +459,19 @@ def cmd_update(args):
371
459
  if args.workspace is not None: sets.append(('workspace_path', args.workspace))
372
460
  if args.agent is not None: sets.append(('agent_id', args.agent))
373
461
  if args.prompt is not None: sets.append(('user_prompt', args.prompt))
462
+ # Recurring vs one-shot, same as the REST PUT: both in one call is an
463
+ # error; one side alone is a mode switch that clears the other (else the
464
+ # runner prefers run_at and its post-fire auto-disable kills the schedule).
465
+ has_schedule = bool(args.schedule and args.schedule.strip())
466
+ run_at_ms = parse_run_at(args.run_at) if args.run_at is not None else None
467
+ if run_at_ms is not None and run_at_ms <= now_ms():
468
+ die('--run-at must be in the future')
469
+ if has_schedule and run_at_ms is not None:
470
+ die('--schedule and --run-at are mutually exclusive')
374
471
  if args.schedule is not None: sets.append(('schedule', args.schedule))
375
- if args.run_at is not None:
376
- run_at_ms = parse_run_at(args.run_at)
377
- if run_at_ms is not None and run_at_ms <= now_ms():
378
- die('--run-at must be in the future')
379
- sets.append(('run_at', run_at_ms))
472
+ elif run_at_ms is not None: sets.append(('schedule', ''))
473
+ if args.run_at is not None: sets.append(('run_at', run_at_ms))
474
+ elif has_schedule: sets.append(('run_at', None))
380
475
  if args.timezone is not None: sets.append(('timezone', args.timezone))
381
476
  if args.timeout_sec is not None:
382
477
  # `--timeout-sec ""` clears back to the runner default (NULL),
@@ -388,6 +483,24 @@ def cmd_update(args):
388
483
  if args.targets is not None:
389
484
  sets.append(('targets', json.dumps(parse_targets(args.targets), ensure_ascii=False)))
390
485
  cur = dict(row)
486
+ if args.dir is not None or args.stateless is not None:
487
+ require_v5_columns(conn)
488
+ workspace = args.workspace if args.workspace is not None else cur['workspace_path']
489
+ if args.dir is not None:
490
+ # `--dir ""` clears back to the workspace root.
491
+ sets.append(('working_dir', parse_dir(workspace, args.dir)))
492
+ elif args.workspace is not None and cur.get('working_dir'):
493
+ # The stored folder must still exist under the new workspace.
494
+ parse_dir(workspace, cur['working_dir'])
495
+ if args.stateless is not None:
496
+ sets.append(('stateless', 1 if args.stateless == 'on' else 0))
497
+ merged = {**cur, **dict(sets)}
498
+ # The agent rule only when this update changes one of its inputs: a job
499
+ # saved before the rule (its session on another agent) must still take an
500
+ # unrelated edit (prompt, schedule, targets, …).
501
+ rule_keys = ('agent_id', 'session_id', 'stateless', 'workspace_path')
502
+ if any(merged.get(k) != cur.get(k) for k in rule_keys):
503
+ check_session_rules(merged)
391
504
  if args.active_from is not None or args.active_until is not None:
392
505
  require_active_columns(conn)
393
506
  # Bare dates / offset-less times read in the job's timezone after
@@ -400,12 +513,14 @@ def cmd_update(args):
400
513
  # Window rules on the merged row (also catches --run-at on a job that
401
514
  # still has a window).
402
515
  merged = {**cur, **dict(sets)}
516
+ if not (merged.get('schedule') or '').strip() and merged.get('run_at') is None:
517
+ die('--schedule or --run-at required (this update would leave the job with neither)')
403
518
  validate_active_window(
404
519
  merged.get('run_at'), merged.get('active_from'), merged.get('active_until'),
405
520
  new_until=bool(args.active_until and args.active_until.strip()),
406
521
  )
407
522
  if not sets:
408
- die('nothing to update — pass at least one --label/--workspace/--agent/--prompt/--schedule/--run-at/--timezone/--timeout-sec/--session/--targets/--active-from/--active-until')
523
+ die('nothing to update — pass at least one --label/--workspace/--agent/--prompt/--schedule/--run-at/--timezone/--timeout-sec/--session/--dir/--stateless/--targets/--active-from/--active-until')
409
524
  sets.append(('updated_at', now_ms()))
410
525
 
411
526
  placeholders = ', '.join(f'{k} = ?' for k, _ in sets)
@@ -492,10 +607,12 @@ def cmd_channels(_):
492
607
 
493
608
  def cmd_runs(args):
494
609
  conn = open_db()
610
+ run_cols = {r[1] for r in conn.execute('PRAGMA table_info(cron_runs)').fetchall()}
495
611
  sql = (
496
612
  'SELECT id, job_id, trigger_kind, status, started_at, completed_at, '
497
- 'exit_code, failure_reason, output, dispatch_results '
498
- 'FROM cron_runs WHERE job_id = ?'
613
+ 'exit_code, failure_reason, output, dispatch_results'
614
+ + (', session_id' if 'session_id' in run_cols else '') +
615
+ ' FROM cron_runs WHERE job_id = ?'
499
616
  )
500
617
  params: list[object] = [args.job_id]
501
618
  if args.before:
@@ -554,6 +671,11 @@ def main():
554
671
  help='max run time in seconds, 60–21600 (default: unset = 3600)')
555
672
  c.add_argument('--session',
556
673
  help="run in this existing root session id (default: the job's own session, same id as the job)")
674
+ c.add_argument('--dir',
675
+ help='workspace sub-folder the run focuses on (relative or absolute, must exist inside the '
676
+ 'workspace; its .halo/INSTRUCTIONS.md chain joins the prompt). Default: the workspace root')
677
+ c.add_argument('--stateless', action='store_true',
678
+ help='start a new session every run (no memory between runs); excludes --session')
557
679
  c.add_argument('--targets',
558
680
  help='comma-separated channelType:accountId[:chatId] list, or JSON array. '
559
681
  'chatId pins delivery to a specific chat (e.g. when scheduling from inside a chat).')
@@ -580,6 +702,9 @@ def main():
580
702
  u.add_argument('--session',
581
703
  help='root session id to run in; pass "" to clear back to the job\'s own session '
582
704
  '(same id as the job — an older job pinned to cron-cron-… then starts a fresh one)')
705
+ u.add_argument('--dir', help='workspace sub-folder the run focuses on; pass "" to clear back to the workspace root')
706
+ u.add_argument('--stateless', choices=['on', 'off'],
707
+ help='on = a new session every run (clear --session first); off = one stable session again')
583
708
  u.add_argument('--targets')
584
709
  u.add_argument('--active-from', dest='active_from',
585
710
  help='recurring only: YYYY-MM-DD (= that day 00:00), ISO-8601 or unix ms, in the job timezone; '
@@ -31,20 +31,30 @@ natural language, infer it from the request.
31
31
 
32
32
  - `<workspace>/.halo/skills/<skill-id>/` — workspace-scoped
33
33
  - `~/.halo/global/skills/<skill-id>/` — shared across all workspaces
34
+ - `~/.halo/global/extensions/<ext-id>/skills/<skill-id>/` — shipped by an
35
+ installed canvas extension. **Read-only**: replaced when the extension is
36
+ reinstalled, gone when it is uninstalled.
34
37
 
35
- A workspace skill **wholly overrides** a global one with the same id — the
36
- whole folder, SKILL.md plus every resource file. There is NO per-file fallback:
37
- if the workspace copy lacks a resource file the global one has, that file is
38
- simply absent. Consequences you must apply:
38
+ Precedence is workspace > global > extension: a higher folder **wholly
39
+ overrides** a lower one with the same id — the whole folder, SKILL.md plus
40
+ every resource file. There is NO per-file fallback: if the overriding copy
41
+ lacks a resource file the lower one has, that file is simply absent.
42
+ Consequences you must apply:
39
43
 
40
44
  - **Create**: ask (or infer) which scope. Default to **workspace** unless the
41
- user says "global" / "all workspaces". If the id already exists globally,
42
- say so — the new workspace folder will shadow the global one here entirely.
45
+ user says "global" / "all workspaces". If the id already exists globally or
46
+ in an extension, say so — the new folder will shadow that one entirely.
43
47
  - **Update**: resolve which folder is actually in effect FIRST — workspace
44
- path if it exists, else global. Edit the live one; never edit global to fix
45
- behavior in a workspace where a workspace copy shadows it. To customize a
46
- global skill for one workspace, copy the WHOLE folder to workspace scope
47
- (including resource files — remember: no per-file fallback), then edit.
48
+ path if it exists, else global, else the extension's. Edit the live one;
49
+ never edit global to fix behavior in a workspace where a workspace copy
50
+ shadows it. To customize a global skill for one workspace, copy the WHOLE
51
+ folder to workspace scope (including resource files — remember: no per-file
52
+ fallback), then edit.
53
+ - **Extension skills**: never edit anything under `~/.halo/global/extensions/`
54
+ (lost on the next reinstall). If the live folder is an extension's, copy the
55
+ WHOLE folder to global or workspace scope first (resource files included —
56
+ a copy holding only SKILL.md shadows the extension's folder and drops its
57
+ resources), then edit the copy.
48
58
  - **Built-in skills** (`agent`, `skill`, `workspace`, `cron`, `send-file`,
49
59
  `self`, `aws-knowledge`, `web-search`): their global copies are force re-seeded on every server
50
60
  start — direct edits to those global files are lost. Customize at workspace
@@ -99,7 +109,8 @@ If the user doesn't say which agent, add it to `default`.
99
109
 
100
110
  ## Update
101
111
 
102
- `file_read` the live SKILL.md (workspace first — see override rules), change
112
+ `file_read` the live SKILL.md (workspace, then global, then extension — see
113
+ override rules; an extension's is copied out before any edit), change
103
114
  only what's requested, write back. Don't rewrite untouched sections. If the
104
115
  skill declares `verbs`, keep the frontmatter list and any builtin handlers in
105
116
  sync (builtin verbs are platform code; only their desc text lives here).
@@ -75,7 +75,9 @@ Excluded:
75
75
 
76
76
  Lead with the most important callouts: anything in the **redactions** section, anything in
77
77
  **missing_skills**, and anything that looks wrong (unexpected agent included, expected one
78
- missing).
78
+ missing). **extension_skills** lists referenced skills that come from an installed canvas
79
+ extension: they are not bundled (read-only, owned by the extension) — tell the user the
80
+ receiver needs to install that extension.
79
81
 
80
82
  ### 4. Walk through suspicious markdown findings
81
83
 
@@ -147,7 +149,8 @@ To share: send the zip. Receiver unzips into their project root, then fills in a
147
149
  workspace agent of the same id**. Those get copied into the bundle's `agents/` so the receiver
148
150
  doesn't depend on having identical globals.
149
151
  - Skills that are **referenced by an included agent's `skills:` list**. Workspace skill wins;
150
- otherwise global skill (if not disabled).
152
+ otherwise global skill (if not disabled). A skill only an installed extension ships is not
153
+ bundled; it is listed under `extension_skills` instead.
151
154
  - `prompts/{bootstrap,all,root}/` — workspace dir if it exists, else global fallback.
152
155
  - `<ws>/.halo/settings.yaml` (if present) — values under any `secrets` map or a secret-named
153
156
  key (`api_key`, `token`, …) are cleared to `""`; `<<ENV>>` placeholders stay. The receiver
@@ -81,27 +81,46 @@ BUILTIN_SKILL_IDS = {
81
81
 
82
82
  def load_disabled(db_path: Path):
83
83
  """Return disabled items from the workspace DB, split by scope:
84
- (global_agents, global_skills, ws_agents, ws_skills).
84
+ (global_agents, global_skills, ws_agents, ws_skills, ext_skills).
85
85
 
86
86
  A disabled item never ships in the bundle (share == "don't take it
87
- along"). We track global and workspace scopes separately because the two
88
- take different code paths below — workspace agents are staged from one
89
- loop, un-overridden globals from another."""
87
+ along"). We track the scopes separately because they take different code
88
+ paths below — workspace agents are staged from one loop, un-overridden
89
+ globals from another, extension skills are only reported. Only a
90
+ `workspace` row affects a workspace item."""
90
91
  if not db_path.exists():
91
- return set(), set(), set(), set()
92
+ return set(), set(), set(), set(), set()
92
93
  try:
93
94
  conn = sqlite3.connect(db_path)
94
95
  cur = conn.execute("SELECT item_type, item_id, scope FROM disabled_items")
95
- g_agents, g_skills, w_agents, w_skills = set(), set(), set(), set()
96
+ g_agents, g_skills, w_agents, w_skills, x_skills = set(), set(), set(), set(), set()
96
97
  for item_type, item_id, scope in cur:
97
98
  if scope == "global":
98
99
  (g_agents if item_type == "agent" else g_skills).add(item_id)
99
- else:
100
+ elif scope == "workspace":
100
101
  (w_agents if item_type == "agent" else w_skills).add(item_id)
102
+ elif scope == "extension" and item_type == "skill":
103
+ x_skills.add(item_id)
101
104
  conn.close()
102
- return g_agents, g_skills, w_agents, w_skills
105
+ return g_agents, g_skills, w_agents, w_skills, x_skills
103
106
  except sqlite3.DatabaseError:
104
- return set(), set(), set(), set()
107
+ return set(), set(), set(), set(), set()
108
+
109
+
110
+ def extension_skill_owner(extensions_dir: Path, sid: str):
111
+ """Id of the installed extension shipping skill `sid`
112
+ (`<ext>/skills/<sid>/SKILL.md`, sid == ext or starts with `ext-`), else
113
+ None. Lower extension id wins, like the server (skill-sources.ts); the
114
+ manifest is only checked for presence, not fully validated."""
115
+ if not extensions_dir.is_dir():
116
+ return None
117
+ for ext in sorted(d.name for d in extensions_dir.iterdir() if d.is_dir() and not d.name.startswith(".")):
118
+ if sid != ext and not sid.startswith(f"{ext}-"):
119
+ continue
120
+ ext_dir = extensions_dir / ext
121
+ if (ext_dir / "halo-extension.json").is_file() and (ext_dir / "skills" / sid / "SKILL.md").is_file():
122
+ return ext
123
+ return None
105
124
 
106
125
 
107
126
  def load_yaml(path: Path):
@@ -278,13 +297,14 @@ def main():
278
297
  "memory_count": 0,
279
298
  "assets_count": 0,
280
299
  "missing_skills": [],
300
+ "extension_skills": [],
281
301
  "settings_skipped": None,
282
302
  "always_excluded": ALWAYS_EXCLUDED,
283
303
  },
284
304
  "redactions": {"yaml_params": [], "settings": [], "markdown_auto": [], "markdown_suspicious": []},
285
305
  }
286
306
 
287
- disabled_agents, disabled_skills, ws_disabled_agents, ws_disabled_skills = load_disabled(ws_halo / "halo.db")
307
+ disabled_agents, disabled_skills, ws_disabled_agents, ws_disabled_skills, ext_disabled_skills = load_disabled(ws_halo / "halo.db")
288
308
 
289
309
  # --- INSTRUCTIONS.md (root + sub-dir chain), with global fallback if no root ws version
290
310
  ws_root_instr = ws_halo / "INSTRUCTIONS.md"
@@ -453,12 +473,21 @@ def main():
453
473
  for sid in sorted(ws_skill_ids):
454
474
  stage_skill(ws_skills_dir / sid, "workspace")
455
475
 
456
- # 2. agent-referenced skills missing from the workspace → global fallback
476
+ # 2. agent-referenced skills missing from the workspace → global fallback,
477
+ # else an installed extension's copy: read-only and owned by the
478
+ # extension, so it isn't bundled — the receiver installs the extension.
457
479
  for sid in sorted(referenced_skills - ws_skill_ids):
458
480
  if sid in BUILTIN_SKILL_IDS:
459
481
  continue # receiver's server seeds these itself
460
- if (global_skills_dir / sid / "SKILL.md").exists() and sid not in disabled_skills:
461
- stage_skill(global_skills_dir / sid, "global")
482
+ if (global_skills_dir / sid / "SKILL.md").exists():
483
+ if sid not in disabled_skills:
484
+ stage_skill(global_skills_dir / sid, "global")
485
+ else:
486
+ manifest["excluded"]["missing_skills"].append(sid)
487
+ continue
488
+ ext = extension_skill_owner(home_global / "extensions", sid)
489
+ if ext and sid not in ext_disabled_skills:
490
+ manifest["excluded"]["extension_skills"].append({"id": sid, "extension": ext})
462
491
  else:
463
492
  manifest["excluded"]["missing_skills"].append(sid)
464
493
 
@@ -506,6 +535,12 @@ def main():
506
535
  f"- `.halo/settings.yaml` ({len(manifest['redactions']['settings'])} secret value(s) cleared)\n"
507
536
  if manifest["included"]["settings"] else ""
508
537
  )
538
+ ext_skills = manifest["excluded"]["extension_skills"]
539
+ ext_line = (
540
+ "- Skills shipped by canvas extensions (not bundled — install the extension to get them): "
541
+ + ", ".join(f"`{s['id']}` (extension `{s['extension']}`)" for s in ext_skills) + "\n"
542
+ if ext_skills else ""
543
+ )
509
544
  readme = (
510
545
  "# Shared Halo Workspace\n\n"
511
546
  "This bundle contains a Halo workspace's `.halo/` configuration: agents, skills,\n"
@@ -528,7 +563,8 @@ def main():
528
563
  "- `USER.md` (personal profile, all scopes)\n"
529
564
  "- `memory/`, `assets/` (workspace-specific)\n"
530
565
  "- `sessions/`, `logs/`, `tmp/`, `*.db*`\n"
531
- "- `~/.halo/secrets/settings.yaml` (global settings, never included)\n\n"
566
+ "- `~/.halo/secrets/settings.yaml` (global settings, never included)\n"
567
+ f"{ext_line}\n"
532
568
  "## Auto-redactions\n\n"
533
569
  f"Markdown files were scanned. {len(manifest['redactions']['markdown_auto'])} unambiguous "
534
570
  f"leak(s) were auto-redacted to `[REDACTED:<type>]`. "
@@ -559,6 +595,7 @@ def main():
559
595
  "excluded_memory_files": manifest["excluded"]["memory_count"],
560
596
  "excluded_asset_files": manifest["excluded"]["assets_count"],
561
597
  "missing_skills": manifest["excluded"]["missing_skills"],
598
+ "extension_skills": [f"{s['id']} (extension {s['extension']})" for s in manifest["excluded"]["extension_skills"]],
562
599
  },
563
600
  }, indent=2, ensure_ascii=False))
564
601
 
@@ -1 +0,0 @@
1
- "use strict";(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[220],{10912:(e,t,n)=>{function l(e,t){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"font-family:sans-serif;padding:40px;max-width:800px;margin:0 auto",l=window.open("","_blank");l&&(l.document.write("<!DOCTYPE html><html><head><title>".concat(e.replace(/[&<>"']/g,e=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[e]),"</title><style>body{").concat(n,"}img{max-width:100%}</style></head><body>").concat(t,"</body></html>")),l.document.close(),l.onload=()=>{l.print()})}n.d(t,{H:()=>l})},39250:(e,t,n)=>{n.d(t,{A:()=>l});let l=(0,n(79020).A)("Printer",[["path",{d:"M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2",key:"143wyd"}],["path",{d:"M6 9V3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v6",key:"1itne7"}],["rect",{x:"6",y:"14",width:"12",height:"8",rx:"1",key:"1ue0tg"}]])},62839:(e,t,n)=>{n.r(t),n.d(t,{MediaPreview:()=>y});var l=n(41440),r=n(54604),a=n(79020);let i=(0,a.A)("ZoomOut",[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["line",{x1:"21",x2:"16.65",y1:"21",y2:"16.65",key:"13gj7c"}],["line",{x1:"8",x2:"14",y1:"11",y2:"11",key:"durymu"}]]),c=(0,a.A)("ZoomIn",[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["line",{x1:"21",x2:"16.65",y1:"21",y2:"16.65",key:"13gj7c"}],["line",{x1:"11",x2:"11",y1:"8",y2:"14",key:"1vmskp"}],["line",{x1:"8",x2:"14",y1:"11",y2:"11",key:"durymu"}]]);var o=n(23715),s=n(39250),u=n(37050),d=n(10912),x=n(27173),h=n(17119),m=n(79906);function y(e){let{name:t,viewUrl:n,downloadUrl:a,onOpenAsText:y}=e,f=(0,h.kj)(),g=function(e){var t,n;let l=null!=(n=null==(t=e.split(".").pop())?void 0:t.toLowerCase())?n:"";return x.dj.includes(l)?"image":x.Tv.includes(l)?"video":x._H.includes(l)?"audio":null}(t),p=function(){let e=(0,r.useRef)(null),[t,n]=(0,r.useState)(1),[l,a]=(0,r.useState)({x:0,y:0}),i=(0,r.useRef)(null),[c,o]=(0,r.useState)(!1),s=(0,r.useCallback)(()=>{n(1),a({x:0,y:0})},[]),u=(0,r.useCallback)((e,t,l)=>{n(n=>{let r=Math.min(20,Math.max(.1,n*l)),i=r/n;return a(n=>({x:e-i*(e-n.x),y:t-i*(t-n.y)})),r})},[]),d=(0,r.useCallback)(t=>{let n=e.current;n&&u(n.clientWidth/2,n.clientHeight/2,t)},[u]),x=(0,r.useCallback)(t=>{var n;t.preventDefault();let l=null==(n=e.current)?void 0:n.getBoundingClientRect();l&&u(t.clientX-l.left,t.clientY-l.top,t.deltaY<0?1.15:.8695652173913044)},[u]),h=(0,r.useCallback)(e=>{0===e.button&&(e.preventDefault(),e.target.setPointerCapture(e.pointerId),i.current={startX:e.clientX,startY:e.clientY,origX:l.x,origY:l.y},o(!0))},[l]),m=(0,r.useCallback)(e=>{i.current&&a({x:i.current.origX+(e.clientX-i.current.startX),y:i.current.origY+(e.clientY-i.current.startY)})},[]),y=(0,r.useCallback)(()=>{i.current=null,o(!1)},[]);return(0,r.useEffect)(()=>{let t=e.current;if(!t)return;let n=e=>e.preventDefault();return t.addEventListener("wheel",n,{passive:!1}),()=>t.removeEventListener("wheel",n)},[]),{containerRef:e,scale:t,translate:l,dragging:c,reset:s,zoomCenter:d,onWheel:x,onPointerDown:h,onPointerMove:m,onPointerUp:y}}(),k="image"===g?(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)(u.IB,{onClick:()=>p.zoomCenter(.8695652173913044),title:f("editor.zoomOut"),children:(0,l.jsx)(i,{className:"h-3 w-3"})}),(0,l.jsxs)("span",{className:"min-w-[3ch] text-center text-[10px] text-[var(--muted-foreground)]",children:[Math.round(100*p.scale),"%"]}),(0,l.jsx)(u.IB,{onClick:()=>p.zoomCenter(1.15),title:f("editor.zoomIn"),children:(0,l.jsx)(c,{className:"h-3 w-3"})}),(0,l.jsx)(u.IB,{onClick:p.reset,title:f("editor.resetZoom"),children:(0,l.jsx)(o.A,{className:"h-3 w-3"})}),(0,l.jsxs)(u.IB,{onClick:()=>(0,d.H)(t,'<img src="'.concat(n,'" />'),"margin:0;display:flex;justify-content:center;align-items:center;min-height:100vh;background:#fff"),title:f("editor.print"),children:[(0,l.jsx)(s.A,{className:"h-3 w-3"}),(0,l.jsx)("span",{children:f("editor.print")})]})]}):null;return(0,l.jsxs)(u.no,{name:t,downloadUrl:a,onOpenAsText:y,extraToolbar:k,children:["image"===g&&(0,l.jsx)("div",{ref:p.containerRef,className:"h-full w-full overflow-hidden bg-[#1a1a1a]",style:{cursor:p.dragging?"grabbing":"grab"},onWheel:p.onWheel,onPointerDown:p.onPointerDown,onPointerMove:p.onPointerMove,onPointerUp:p.onPointerUp,onPointerCancel:p.onPointerUp,children:(0,l.jsx)(m.p,{src:n,alt:t,draggable:!1,placeholderClassName:"h-full w-full rounded-none bg-white/5 text-white/60",style:{transformOrigin:"0 0",transform:"translate(".concat(p.translate.x,"px, ").concat(p.translate.y,"px) scale(").concat(p.scale,")")}})}),"video"===g&&(0,l.jsx)("div",{className:"flex h-full items-center justify-center bg-black p-4",children:(0,l.jsx)("video",{src:n,controls:!0,className:"max-h-full max-w-full",children:(0,l.jsx)("track",{kind:"captions"})})}),"audio"===g&&(0,l.jsx)("div",{className:"flex h-full flex-col items-center justify-center gap-4 bg-[var(--background)]",children:(0,l.jsx)("audio",{src:n,controls:!0,className:"w-full max-w-md"})})]})}}}]);