@turmind/halo 1.6.0 → 1.6.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.
Files changed (58) 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-be47983ba8ff20b1.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 +22 -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 +31 -8
  15. package/bundled-docs/design/ws.md +1 -1
  16. package/bundled-docs/dev/add-model-provider.md +15 -9
  17. package/bundled-docs/dev/api.md +23 -10
  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 +7 -3
  28. package/bundled-docs/requirements/command.md +4 -0
  29. package/bundled-docs/requirements/editor.md +20 -6
  30. package/bundled-docs/requirements/skill.md +15 -9
  31. package/dist/index.js +2263 -1324
  32. package/package.json +1 -1
  33. package/templates/models/anthropic.yaml +109 -13
  34. package/templates/models/aws-bedrock-claude-invoke.yaml +39 -13
  35. package/templates/models/aws-bedrock-mantle.yaml +60 -28
  36. package/templates/models/aws-bedrock-openai.yaml +52 -29
  37. package/templates/models/deepseek.yaml +7 -3
  38. package/templates/models/doubao.yaml +72 -20
  39. package/templates/models/hunyuan.yaml +74 -14
  40. package/templates/models/kimi.yaml +63 -3
  41. package/templates/models/mimo-token-plan-china.yaml +9 -1
  42. package/templates/models/minimax.yaml +9 -3
  43. package/templates/models/openai.yaml +120 -17
  44. package/templates/models/qwen.yaml +90 -4
  45. package/templates/models/zhipu.yaml +9 -1
  46. package/templates/schema.sql +1 -1
  47. package/templates/skills/cron/SKILL.md +38 -2
  48. package/templates/skills/cron/manage-cron.py +115 -7
  49. package/templates/skills/skill/SKILL.md +22 -11
  50. package/templates/skills/workspace/share.md +5 -2
  51. package/templates/skills/workspace/stage.py +51 -14
  52. package/admin-out/_next/static/chunks/220.7566bc89f3d98993.js +0 -1
  53. package/admin-out/_next/static/chunks/989-a7f752480eaaa2dc.js +0 -1
  54. package/admin-out/_next/static/chunks/app/page-8173112f256bc483.js +0 -1
  55. package/admin-out/_next/static/chunks/webpack-dc73047e0279979b.js +0 -1
  56. package/admin-out/_next/static/css/981c9e5610ce535a.css +0 -1
  57. /package/admin-out/_next/static/{_F_Fkyh9PainUNY82EEjY → j6buZnQDPISEFgtAuTGyl}/_buildManifest.js +0 -0
  58. /package/admin-out/_next/static/{_F_Fkyh9PainUNY82EEjY → j6buZnQDPISEFgtAuTGyl}/_ssgManifest.js +0 -0
@@ -142,6 +142,70 @@ def parse_session(raw: str | None) -> str | None:
142
142
  return v
143
143
 
144
144
 
145
+ def parse_dir(workspace: str, raw: str | None) -> str | None:
146
+ """Validate --dir against the job's workspace (same rule as the admin REST
147
+ routes / `halo cli --dir`): workspace-relative or absolute, inside the
148
+ workspace, an existing directory. Returns the workspace-relative path;
149
+ empty / the workspace root = None (= the root)."""
150
+ if raw is None or not raw.strip():
151
+ return None
152
+ v = raw.strip()
153
+ root = os.path.abspath(workspace)
154
+ resolved = os.path.abspath(v if os.path.isabs(v) else os.path.join(root, v))
155
+ if resolved != root and not resolved.startswith(root + os.sep):
156
+ die(f'invalid --dir "{raw}": outside the workspace {root}')
157
+ if not os.path.exists(resolved):
158
+ die(f'invalid --dir "{raw}": does not exist')
159
+ if not os.path.isdir(resolved):
160
+ die(f'invalid --dir "{raw}": not a directory')
161
+ rel = os.path.relpath(resolved, root)
162
+ return None if rel == '.' else rel
163
+
164
+
165
+ def effective_session_id(job_id: str, session_id: str | None) -> str:
166
+ """runner.ts effectiveSessionId: the picked session, else the job's own
167
+ (the job id; `cron-<id>` for an id without that prefix)."""
168
+ return session_id or (job_id if job_id.startswith('cron-') else f'cron-{job_id}')
169
+
170
+
171
+ def session_agent(workspace: str, session_id: str) -> str | None:
172
+ """Agent of an existing session in the workspace db, else None."""
173
+ db = Path(workspace) / '.halo' / 'halo.db'
174
+ if not db.exists():
175
+ return None
176
+ try:
177
+ # as_uri() percent-encodes `?` / `#` / `%` in the path.
178
+ conn = sqlite3.connect(db.resolve().as_uri() + '?mode=ro', uri=True)
179
+ row = conn.execute('SELECT agent_id FROM agent_sessions WHERE id = ?', (session_id,)).fetchone()
180
+ conn.close()
181
+ except sqlite3.Error:
182
+ return None
183
+ return row[0] if row else None
184
+
185
+
186
+ def check_session_rules(job: dict):
187
+ """Write-point rules on the job's final state (same as the REST routes):
188
+ stateless excludes a picked session, and a stateful job's agent is fixed
189
+ once its session exists."""
190
+ if job.get('stateless') and job.get('session_id'):
191
+ die('--stateless (new session every run) and --session are mutually exclusive')
192
+ if job.get('stateless'):
193
+ return
194
+ sid = effective_session_id(job['id'], job.get('session_id'))
195
+ agent = session_agent(job['workspace_path'], sid)
196
+ if agent is not None and agent != job['agent_id']:
197
+ die(f'session {sid} already runs agent {agent}; delete that session or turn on '
198
+ f'"new session every run" (--stateless) to change the agent')
199
+
200
+
201
+ def require_v5_columns(conn: sqlite3.Connection):
202
+ """cron.db gains working_dir/stateless via the server's boot migration."""
203
+ cols = {r[1] for r in conn.execute('PRAGMA table_info(cron_jobs)').fetchall()}
204
+ if 'working_dir' not in cols or 'stateless' not in cols:
205
+ die('cron.db has no working_dir/stateless columns yet — restart the halo server '
206
+ 'once so it migrates cron.db, then retry')
207
+
208
+
145
209
  def parse_run_at(raw: str | None) -> int | None:
146
210
  """Accepts ISO-8601 (`2026-05-24T09:00:00`) or a unix-ms integer; returns
147
211
  epoch ms or None. Local time without offset is parsed as host TZ —
@@ -254,6 +318,11 @@ def row_to_job(row: sqlite3.Row) -> dict:
254
318
  except Exception:
255
319
  j['targets'] = []
256
320
  j['enabled'] = bool(j['enabled'])
321
+ # Folder + stateless (pre-migration dbs: unset) and the session this job
322
+ # runs in — `null` for stateless (a new one every run).
323
+ j.setdefault('working_dir', None)
324
+ j['stateless'] = bool(j.get('stateless') or 0)
325
+ j['effective_session_id'] = None if j['stateless'] else effective_session_id(j['id'], j.get('session_id'))
257
326
  # Active window: ms (as stored) + ISO in the job's timezone. Pre-migration
258
327
  # dbs have no columns — report them as unset.
259
328
  for k in ('active_from', 'active_until'):
@@ -293,9 +362,11 @@ def cmd_get(args):
293
362
  if not row:
294
363
  die(f'job {args.id} not found', 2)
295
364
  job = row_to_job(row)
365
+ run_cols = {r[1] for r in conn.execute('PRAGMA table_info(cron_runs)').fetchall()}
296
366
  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',
367
+ 'SELECT id, status, started_at, completed_at, exit_code, failure_reason'
368
+ + (', session_id' if 'session_id' in run_cols else '') +
369
+ ' FROM cron_runs WHERE job_id = ? ORDER BY id DESC LIMIT 5',
299
370
  (args.id,),
300
371
  ).fetchall()
301
372
  job['recentRuns'] = [dict(r) for r in runs]
@@ -317,11 +388,16 @@ def cmd_create(args):
317
388
  die('--run-at must be in the future')
318
389
  timeout_sec = parse_timeout_sec(args.timeout_sec)
319
390
  session_id = parse_session(args.session)
391
+ working_dir = parse_dir(args.workspace, args.dir)
320
392
  active_from = parse_active_bound('--active-from', args.active_from or '', args.timezone)
321
393
  active_until = parse_active_bound('--active-until', args.active_until or '', args.timezone)
322
394
  validate_active_window(run_at_ms, active_from, active_until, new_until=True)
323
395
 
324
396
  job_id = args.id or gen_job_id()
397
+ check_session_rules({
398
+ 'id': job_id, 'workspace_path': args.workspace, 'agent_id': args.agent or 'default',
399
+ 'session_id': session_id, 'stateless': args.stateless,
400
+ })
325
401
  now = now_ms()
326
402
  targets = parse_targets(args.targets) if args.targets else []
327
403
 
@@ -346,8 +422,12 @@ def cmd_create(args):
346
422
  'created_at': now,
347
423
  'updated_at': now,
348
424
  }
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.
425
+ # Only name the newer columns when used, so plain jobs can still be
426
+ # created on a cron.db the server hasn't migrated yet.
427
+ if working_dir is not None or args.stateless:
428
+ require_v5_columns(conn)
429
+ values['working_dir'] = working_dir
430
+ values['stateless'] = 1 if args.stateless else 0
351
431
  if active_from is not None or active_until is not None:
352
432
  require_active_columns(conn)
353
433
  values['active_from'] = active_from
@@ -388,6 +468,24 @@ def cmd_update(args):
388
468
  if args.targets is not None:
389
469
  sets.append(('targets', json.dumps(parse_targets(args.targets), ensure_ascii=False)))
390
470
  cur = dict(row)
471
+ if args.dir is not None or args.stateless is not None:
472
+ require_v5_columns(conn)
473
+ workspace = args.workspace if args.workspace is not None else cur['workspace_path']
474
+ if args.dir is not None:
475
+ # `--dir ""` clears back to the workspace root.
476
+ sets.append(('working_dir', parse_dir(workspace, args.dir)))
477
+ elif args.workspace is not None and cur.get('working_dir'):
478
+ # The stored folder must still exist under the new workspace.
479
+ parse_dir(workspace, cur['working_dir'])
480
+ if args.stateless is not None:
481
+ sets.append(('stateless', 1 if args.stateless == 'on' else 0))
482
+ merged = {**cur, **dict(sets)}
483
+ # The agent rule only when this update changes one of its inputs: a job
484
+ # saved before the rule (its session on another agent) must still take an
485
+ # unrelated edit (prompt, schedule, targets, …).
486
+ rule_keys = ('agent_id', 'session_id', 'stateless', 'workspace_path')
487
+ if any(merged.get(k) != cur.get(k) for k in rule_keys):
488
+ check_session_rules(merged)
391
489
  if args.active_from is not None or args.active_until is not None:
392
490
  require_active_columns(conn)
393
491
  # Bare dates / offset-less times read in the job's timezone after
@@ -405,7 +503,7 @@ def cmd_update(args):
405
503
  new_until=bool(args.active_until and args.active_until.strip()),
406
504
  )
407
505
  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')
506
+ 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
507
  sets.append(('updated_at', now_ms()))
410
508
 
411
509
  placeholders = ', '.join(f'{k} = ?' for k, _ in sets)
@@ -492,10 +590,12 @@ def cmd_channels(_):
492
590
 
493
591
  def cmd_runs(args):
494
592
  conn = open_db()
593
+ run_cols = {r[1] for r in conn.execute('PRAGMA table_info(cron_runs)').fetchall()}
495
594
  sql = (
496
595
  '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 = ?'
596
+ 'exit_code, failure_reason, output, dispatch_results'
597
+ + (', session_id' if 'session_id' in run_cols else '') +
598
+ ' FROM cron_runs WHERE job_id = ?'
499
599
  )
500
600
  params: list[object] = [args.job_id]
501
601
  if args.before:
@@ -554,6 +654,11 @@ def main():
554
654
  help='max run time in seconds, 60–21600 (default: unset = 3600)')
555
655
  c.add_argument('--session',
556
656
  help="run in this existing root session id (default: the job's own session, same id as the job)")
657
+ c.add_argument('--dir',
658
+ help='workspace sub-folder the run focuses on (relative or absolute, must exist inside the '
659
+ 'workspace; its .halo/INSTRUCTIONS.md chain joins the prompt). Default: the workspace root')
660
+ c.add_argument('--stateless', action='store_true',
661
+ help='start a new session every run (no memory between runs); excludes --session')
557
662
  c.add_argument('--targets',
558
663
  help='comma-separated channelType:accountId[:chatId] list, or JSON array. '
559
664
  'chatId pins delivery to a specific chat (e.g. when scheduling from inside a chat).')
@@ -580,6 +685,9 @@ def main():
580
685
  u.add_argument('--session',
581
686
  help='root session id to run in; pass "" to clear back to the job\'s own session '
582
687
  '(same id as the job — an older job pinned to cron-cron-… then starts a fresh one)')
688
+ u.add_argument('--dir', help='workspace sub-folder the run focuses on; pass "" to clear back to the workspace root')
689
+ u.add_argument('--stateless', choices=['on', 'off'],
690
+ help='on = a new session every run (clear --session first); off = one stable session again')
583
691
  u.add_argument('--targets')
584
692
  u.add_argument('--active-from', dest='active_from',
585
693
  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"})})]})}}}]);