@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.
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/58.d26b1c4f14404226.js +1 -0
- package/admin-out/_next/static/chunks/989-ddba0eb72566da23.js +1 -0
- package/admin-out/_next/static/chunks/app/page-1d27029caba48956.js +1 -0
- package/admin-out/_next/static/chunks/webpack-74f577c1b094f942.js +1 -0
- package/admin-out/_next/static/css/3b4dcd0ac6b8eae8.css +1 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/agent.md +6 -4
- package/bundled-docs/design/canvas-extensions.md +30 -4
- package/bundled-docs/design/cron.md +24 -11
- package/bundled-docs/design/prompt-system.md +10 -5
- package/bundled-docs/design/session.md +4 -1
- package/bundled-docs/design/storage.md +36 -11
- package/bundled-docs/design/ws.md +7 -5
- package/bundled-docs/dev/add-model-provider.md +17 -11
- package/bundled-docs/dev/api.md +25 -12
- package/bundled-docs/dev/deploy.md +3 -0
- package/bundled-docs/dev/desktop-packaging.md +11 -5
- package/bundled-docs/dev/env.md +1 -1
- package/bundled-docs/dev/previews.md +26 -0
- package/bundled-docs/guide/agents.md +6 -6
- package/bundled-docs/guide/cli.md +5 -4
- package/bundled-docs/guide/skills.md +12 -6
- package/bundled-docs/guide/workspace.md +1 -1
- package/bundled-docs/requirements/agent.md +5 -0
- package/bundled-docs/requirements/chat.md +9 -5
- package/bundled-docs/requirements/command.md +4 -0
- package/bundled-docs/requirements/editor.md +20 -6
- package/bundled-docs/requirements/session.md +1 -1
- package/bundled-docs/requirements/skill.md +15 -9
- package/dist/index.js +2332 -1348
- package/package.json +1 -1
- package/templates/models/anthropic.yaml +109 -13
- package/templates/models/aws-bedrock-claude-invoke.yaml +39 -13
- package/templates/models/aws-bedrock-mantle.yaml +60 -28
- package/templates/models/aws-bedrock-openai.yaml +52 -29
- package/templates/models/deepseek.yaml +19 -10
- package/templates/models/doubao.yaml +72 -20
- package/templates/models/hunyuan.yaml +79 -15
- package/templates/models/kimi.yaml +63 -3
- package/templates/models/mimo-token-plan-china.yaml +9 -1
- package/templates/models/minimax.yaml +9 -3
- package/templates/models/openai.yaml +120 -17
- package/templates/models/qwen.yaml +103 -6
- package/templates/models/zhipu.yaml +9 -1
- package/templates/schema.sql +1 -1
- package/templates/skills/cron/SKILL.md +40 -3
- package/templates/skills/cron/manage-cron.py +140 -15
- package/templates/skills/skill/SKILL.md +22 -11
- package/templates/skills/workspace/share.md +5 -2
- package/templates/skills/workspace/stage.py +51 -14
- package/admin-out/_next/static/chunks/220.7566bc89f3d98993.js +0 -1
- package/admin-out/_next/static/chunks/989-a7f752480eaaa2dc.js +0 -1
- package/admin-out/_next/static/chunks/app/page-8173112f256bc483.js +0 -1
- package/admin-out/_next/static/chunks/webpack-dc73047e0279979b.js +0 -1
- package/admin-out/_next/static/css/981c9e5610ce535a.css +0 -1
- /package/admin-out/_next/static/{_F_Fkyh9PainUNY82EEjY → VgAe62AuGEiYrF4JQ7WQe}/_buildManifest.js +0 -0
- /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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
'
|
|
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
|
|
350
|
-
#
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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;
|
|
45
|
-
behavior in a workspace where a workspace copy
|
|
46
|
-
global skill for one workspace, copy the WHOLE
|
|
47
|
-
(including resource files — remember: no per-file
|
|
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
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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()
|
|
461
|
-
|
|
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
|
|
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=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[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"})})]})}}}]);
|