space-architect 5.2.1 → 5.3.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/lib/space_architect/architect_project.rb +132 -25
- data/lib/space_architect/cli/architect.rb +291 -29
- data/lib/space_architect/conversations_client.rb +88 -0
- data/lib/space_architect/harness.rb +18 -13
- data/lib/space_architect/jobs_client.rb +147 -0
- data/lib/space_architect/session_sync/bin_path.rb +28 -0
- data/lib/space_architect/session_sync/cursor.rb +43 -0
- data/lib/space_architect/session_sync/plist.rb +95 -0
- data/lib/space_architect/session_sync/runner.rb +62 -0
- data/lib/space_architect/session_sync/session_id.rb +20 -0
- data/lib/space_architect/session_sync.rb +45 -0
- data/lib/space_architect.rb +2 -0
- data/lib/space_core/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: de2f5d7d6f80acc7b6a7bd16c9ec3dbaf23ef9c2d425c8b1e532f5c24822533a
|
|
4
|
+
data.tar.gz: b39f07d1d24b1288d54e37a08624322ad0fc4f5a664459d6bdcbe12acf4d0438
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5086b34800652f32d243dce10163543a6ee182096b2a42e6938c303de99cf3fa9212591cadba7494491f04f505e16a2c569388c2434c2bbca59585ece77671a9
|
|
7
|
+
data.tar.gz: 5824a84007e85b84a19ce50c0af0ed6318315b3060caceb5ab4e560416da098d980155bbe74833560a1f7b35330bafb2e73a25e3154f28dc08cf6f53c2ed14e8
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [5.3.0] - 2026-07-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`architect sessions sync`** — scans local pi (`~/.pi/agent/sessions`) and
|
|
13
|
+
claude (`~/.claude/projects`) session stores and uploads new/grown `.jsonl`
|
|
14
|
+
files to a space-server over the bearer-authenticated `POST /conversations`
|
|
15
|
+
wire contract. Per-file `{size, mtime}` cursor at
|
|
16
|
+
`$XDG_STATE_HOME/space-architect/session-sync.yaml`, a 60-second recent-mtime
|
|
17
|
+
guard against mid-write reads, `--dry-run`, and per-file
|
|
18
|
+
uploaded/updated/skipped/failed reporting (non-zero exit on any failure).
|
|
19
|
+
An `op://` token is resolved once per run via `op read`.
|
|
20
|
+
- **`architect sessions agent install|uninstall|status`** — manages a per-user
|
|
21
|
+
launchd agent (`io.github.jetpks.space-architect.session-sync`, default
|
|
22
|
+
`StartInterval` 900s) that runs the sync on an interval. An `op://` token is
|
|
23
|
+
stored in the plist as the ref, never as a resolved secret.
|
|
24
|
+
- **Server: bearer branch + upsert on `POST /conversations`** — machine uploads
|
|
25
|
+
authenticate with the ingest bearer token and upsert by `(user, session_id)`:
|
|
26
|
+
re-uploading a grown session file updates the existing conversation row
|
|
27
|
+
(source file replaced, import re-enqueued) instead of duplicating it.
|
|
28
|
+
The browser upload path is unchanged.
|
|
29
|
+
- **Server: run-fidelity surfaces** — the pi opening prompt is persisted and
|
|
30
|
+
rendered as a leading user turn on Runs/Show (owner-gated); runs/jobs indexes
|
|
31
|
+
carry harness/model/lane and an owner-gated prompt snippet; absolute
|
|
32
|
+
timestamps render the wire value honestly (RFC3339 offsets, millis only when
|
|
33
|
+
present).
|
|
34
|
+
|
|
8
35
|
## [5.2.1] - 2026-07-19
|
|
9
36
|
|
|
10
37
|
### Fixed
|
|
@@ -841,20 +841,8 @@ module Space::Architect
|
|
|
841
841
|
raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
|
|
842
842
|
lane_entry = ensure_lane_materialized(iteration, lane)
|
|
843
843
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
stored_harness: lane_entry["harness"], stored_model: lane_entry["model"])
|
|
847
|
-
|
|
848
|
-
resolved_effort =
|
|
849
|
-
if effort
|
|
850
|
-
Harness.validate_thinking_level!(effort) unless force
|
|
851
|
-
effort
|
|
852
|
-
elsif suffix_level
|
|
853
|
-
err.puts "thinking: model suffix ':#{suffix_level}' parsed from --model → level=#{suffix_level} (model stripped to '#{model}')"
|
|
854
|
-
suffix_level
|
|
855
|
-
else
|
|
856
|
-
lane_entry["effort"]
|
|
857
|
-
end
|
|
844
|
+
resolved_harness, resolved_model, resolved_effort =
|
|
845
|
+
resolve_dispatch_harness(lane_entry, model: model, harness: harness, effort: effort, force: force, err: err)
|
|
858
846
|
|
|
859
847
|
raise Space::Core::Error, "--push-host is only supported with the claude-code harness" \
|
|
860
848
|
if push_host && resolved_harness != "claude-code"
|
|
@@ -870,17 +858,7 @@ module Space::Architect
|
|
|
870
858
|
|
|
871
859
|
# --prompt: the caller authors the lane prompt anywhere (a fresh scratch file)
|
|
872
860
|
# and the CLI owns the canonical copy — byte-for-byte, like variant_add.
|
|
873
|
-
|
|
874
|
-
src = Pathname.new(prompt)
|
|
875
|
-
raise Space::Core::Error, "prompt file not found: #{src}" unless src.exist?
|
|
876
|
-
File.open(prompt_path, "wb") { |f| f.write(File.binread(src)) }
|
|
877
|
-
end
|
|
878
|
-
|
|
879
|
-
raise Space::Core::Error, "prompt.md not found: #{prompt_path}" unless prompt_path.exist?
|
|
880
|
-
|
|
881
|
-
prompt_content = prompt_path.read.strip
|
|
882
|
-
raise Space::Core::Error, "Write this lane's prompt to #{prompt_path} before dispatching." \
|
|
883
|
-
if prompt_content.empty? || prompt_content == PROMPT_STUB.strip
|
|
861
|
+
copy_and_validate_prompt!(prompt, prompt_path)
|
|
884
862
|
|
|
885
863
|
bin = resolved_harness == "claude-code" ? claude_bin : opencode_bin
|
|
886
864
|
harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns, bin: bin,
|
|
@@ -941,6 +919,66 @@ module Space::Architect
|
|
|
941
919
|
end
|
|
942
920
|
end
|
|
943
921
|
|
|
922
|
+
# Submit the lane's builder run as a job to the space-server's queue instead of
|
|
923
|
+
# running it locally: the sandboxed executor mounts the lane worktree + the repo
|
|
924
|
+
# checkout at their identical host absolute paths (so the worktree's gitdir
|
|
925
|
+
# pointer resolves) and runs the harness itself — no local run.jsonl, the
|
|
926
|
+
# transcript lives server-side. jobs_client: is the injectable seam (mirrors
|
|
927
|
+
# dispatch's run_creator:).
|
|
928
|
+
def dispatch_as_job(iteration, lane, host:, token:, backend_url:, model: nil, harness: nil,
|
|
929
|
+
max_turns: 200, effort: nil, force: false, quiet: false,
|
|
930
|
+
job_model: nil, api_key_ref: nil, prompt: nil, jobs_client: nil, now: Time.now)
|
|
931
|
+
err = quiet ? File.open(File::NULL, "w") : $stderr
|
|
932
|
+
|
|
933
|
+
entry = slice_entry(iteration)
|
|
934
|
+
lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
|
|
935
|
+
raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
|
|
936
|
+
lane_entry = ensure_lane_materialized(iteration, lane)
|
|
937
|
+
|
|
938
|
+
resolved_harness, resolved_model, resolved_effort =
|
|
939
|
+
resolve_dispatch_harness(lane_entry, model: model, harness: harness, effort: effort, force: force, err: err)
|
|
940
|
+
raise Space::Core::Error, "--as-job only supports the claude-code harness (lane '#{lane}' resolves to '#{resolved_harness}')" \
|
|
941
|
+
unless resolved_harness == "claude-code"
|
|
942
|
+
|
|
943
|
+
id = iteration_id(entry)
|
|
944
|
+
wt_path = space.path.join(lane_entry["worktree"] || "build/#{id}-#{lane}/wt")
|
|
945
|
+
raise Space::Core::Error, "Worktree directory does not exist: #{wt_path}" unless wt_path.exist?
|
|
946
|
+
|
|
947
|
+
build_dir = space.path.join("build", "#{id}-#{lane}")
|
|
948
|
+
prompt_path = build_dir.join("prompt.md")
|
|
949
|
+
copy_and_validate_prompt!(prompt, prompt_path)
|
|
950
|
+
|
|
951
|
+
repo_path = space.path.join("repos", lane_entry["repo"])
|
|
952
|
+
harness_obj = Harness.for(resolved_harness, model: resolved_model, max_turns: max_turns,
|
|
953
|
+
effort: resolved_effort, force: force, err: err)
|
|
954
|
+
|
|
955
|
+
spec = job_spec(iteration: iteration, lane: lane, wt_path: wt_path, build_dir: build_dir,
|
|
956
|
+
repo_path: repo_path, prompt_content: prompt_path.read, backend_url: backend_url,
|
|
957
|
+
job_model: job_model, api_key_ref: api_key_ref, harness_args: harness_obj.builder_args)
|
|
958
|
+
|
|
959
|
+
client = jobs_client || JobsClient.new(host, token)
|
|
960
|
+
job_id = client.create(spec)
|
|
961
|
+
|
|
962
|
+
# Dispatch bookkeeping, mirroring the local path's dispatched_at stamp: a
|
|
963
|
+
# re-dispatch overwrites the prior job_id, so `architect status` always
|
|
964
|
+
# reflects the last dispatch regardless of mode.
|
|
965
|
+
update_architect_block do |b|
|
|
966
|
+
(b["iterations"] || []).each do |s|
|
|
967
|
+
next unless s["name"] == iteration
|
|
968
|
+
(s["lanes"] || []).each do |l|
|
|
969
|
+
next unless l["name"] == lane
|
|
970
|
+
l["dispatched_at"] = now.iso8601
|
|
971
|
+
l["job_id"] = job_id
|
|
972
|
+
end
|
|
973
|
+
end
|
|
974
|
+
b
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
result = { job_id: job_id, spec: spec }
|
|
978
|
+
result[:prompt_copied] = prompt_path if prompt
|
|
979
|
+
result
|
|
980
|
+
end
|
|
981
|
+
|
|
944
982
|
private
|
|
945
983
|
|
|
946
984
|
attr_reader :space
|
|
@@ -1087,6 +1125,75 @@ module Space::Architect
|
|
|
1087
1125
|
[resolved_harness, resolved_model]
|
|
1088
1126
|
end
|
|
1089
1127
|
|
|
1128
|
+
# --prompt: the caller authors the lane prompt anywhere (a fresh scratch file) and
|
|
1129
|
+
# the CLI owns the canonical copy — byte-for-byte, like variant_add. Shared by
|
|
1130
|
+
# dispatch and dispatch_as_job so both refuse the same empty/stub prompt.
|
|
1131
|
+
def copy_and_validate_prompt!(prompt, prompt_path)
|
|
1132
|
+
if prompt
|
|
1133
|
+
src = Pathname.new(prompt)
|
|
1134
|
+
raise Space::Core::Error, "prompt file not found: #{src}" unless src.exist?
|
|
1135
|
+
File.open(prompt_path, "wb") { |f| f.write(File.binread(src)) }
|
|
1136
|
+
end
|
|
1137
|
+
|
|
1138
|
+
raise Space::Core::Error, "prompt.md not found: #{prompt_path}" unless prompt_path.exist?
|
|
1139
|
+
|
|
1140
|
+
prompt_content = prompt_path.read.strip
|
|
1141
|
+
raise Space::Core::Error, "Write this lane's prompt to #{prompt_path} before dispatching." \
|
|
1142
|
+
if prompt_content.empty? || prompt_content == PROMPT_STUB.strip
|
|
1143
|
+
end
|
|
1144
|
+
|
|
1145
|
+
# Resolve harness/model/effort for a dispatch (local or --as-job), shared so the
|
|
1146
|
+
# two dispatch paths can never drift on precedence or thinking-level translation.
|
|
1147
|
+
def resolve_dispatch_harness(lane_entry, model:, harness:, effort:, force:, err:)
|
|
1148
|
+
model, suffix_level = Harness.parse_model_suffix(model)
|
|
1149
|
+
resolved_harness, resolved_model = resolve_harness_model(harness, model,
|
|
1150
|
+
stored_harness: lane_entry["harness"], stored_model: lane_entry["model"])
|
|
1151
|
+
|
|
1152
|
+
resolved_effort =
|
|
1153
|
+
if effort
|
|
1154
|
+
Harness.validate_thinking_level!(effort) unless force
|
|
1155
|
+
effort
|
|
1156
|
+
elsif suffix_level
|
|
1157
|
+
err.puts "thinking: model suffix ':#{suffix_level}' parsed from --model → level=#{suffix_level} (model stripped to '#{model}')"
|
|
1158
|
+
suffix_level
|
|
1159
|
+
else
|
|
1160
|
+
lane_entry["effort"]
|
|
1161
|
+
end
|
|
1162
|
+
|
|
1163
|
+
[resolved_harness, resolved_model, resolved_effort]
|
|
1164
|
+
end
|
|
1165
|
+
|
|
1166
|
+
# Compose a dispatch --as-job spec per the space-server's job contract: prompt +
|
|
1167
|
+
# workspace.dir (the lane worktree) + environment (deps/network/mounts/env or
|
|
1168
|
+
# secrets) + harness (claude/backend/args) + provenance. mounts are src:dst with
|
|
1169
|
+
# src == dst — the sandboxed executor requires the lane worktree AND the repo
|
|
1170
|
+
# checkout mounted at their identical host absolute paths for the worktree's
|
|
1171
|
+
# gitdir pointer to resolve.
|
|
1172
|
+
def job_spec(iteration:, lane:, wt_path:, build_dir:, repo_path:, prompt_content:, backend_url:,
|
|
1173
|
+
job_model:, api_key_ref:, harness_args:)
|
|
1174
|
+
harness = { "type" => "claude", "backend" => { "base_url" => backend_url }, "args" => harness_args }
|
|
1175
|
+
harness["model"] = job_model if job_model
|
|
1176
|
+
harness["backend"]["api_key_ref"] = api_key_ref if api_key_ref
|
|
1177
|
+
|
|
1178
|
+
environment = {
|
|
1179
|
+
"env" => api_key_ref ? {} : { "ANTHROPIC_API_KEY" => "unused-for-keyless-backends" },
|
|
1180
|
+
"deps" => ["git"],
|
|
1181
|
+
"permissions" => {
|
|
1182
|
+
"network" => true,
|
|
1183
|
+
"mounts" => ["#{build_dir}:#{build_dir}", "#{repo_path}:#{repo_path}"]
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
environment["secrets"] = [{ "ref" => api_key_ref, "name" => "ANTHROPIC_API_KEY" }] if api_key_ref
|
|
1187
|
+
|
|
1188
|
+
{
|
|
1189
|
+
"prompt" => prompt_content,
|
|
1190
|
+
"workspace" => { "dir" => wt_path.to_s },
|
|
1191
|
+
"environment" => environment,
|
|
1192
|
+
"harness" => harness,
|
|
1193
|
+
"provenance" => { "space" => Space::Core::Slugger.slug(space.title), "iteration" => iteration, "lane" => lane }
|
|
1194
|
+
}
|
|
1195
|
+
end
|
|
1196
|
+
|
|
1090
1197
|
def recorded_lane_fields(lane_entry)
|
|
1091
1198
|
{
|
|
1092
1199
|
harness: lane_entry["harness"] || "claude-code",
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "space_src/launchd/agent"
|
|
6
|
+
require_relative "../jobs_client"
|
|
7
|
+
require_relative "../conversations_client"
|
|
8
|
+
require_relative "../session_sync"
|
|
4
9
|
|
|
5
10
|
module Space::Architect
|
|
6
11
|
module CLI
|
|
@@ -323,49 +328,81 @@ module Space::Architect
|
|
|
323
328
|
option :push_url, default: nil, desc: "HTTP endpoint for streaming push (POST body to this URL)"
|
|
324
329
|
option :push_token, default: nil, desc: "Bearer token for push endpoint authorization"
|
|
325
330
|
option :push_host, default: nil, desc: "Base URL of the ingest server; the CLI creates a run via POST <host>/runs and streams to /runs/<id>/ingest (requires --push-token)"
|
|
331
|
+
option :as_job, type: :boolean, default: false, desc: "Submit the run as a job to the space-server's queue instead of running locally (sandboxed executor; requires --host/--token/--backend-url)"
|
|
332
|
+
option :host, default: nil, desc: "Base URL of the space-server (required with --as-job)"
|
|
333
|
+
option :token, default: nil, desc: "Bearer token for authorization (required with --as-job)"
|
|
334
|
+
option :backend_url, default: nil, desc: "Base URL of the harness backend the sandboxed job talks to (required with --as-job)"
|
|
335
|
+
option :job_model, default: nil, desc: "Model pinned for the sandboxed job's harness — distinct from --model, which is meaningless against a non-Anthropic backend"
|
|
336
|
+
option :api_key_ref, default: nil, desc: "op:// reference resolved into ANTHROPIC_API_KEY server-side (--as-job only; omit for keyless backends)"
|
|
326
337
|
|
|
327
338
|
def call(iteration:, lane:, space: nil, prompt: nil, model: nil,
|
|
328
339
|
max_turns: "200", harness: nil, effort: nil, thinking: nil, reasoning: nil,
|
|
329
340
|
force_effort: nil, force_thinking: nil, force_reasoning: nil, quiet: false, detach: false,
|
|
330
|
-
timeout: "14400", push_url: nil, push_token: nil, push_host: nil,
|
|
341
|
+
timeout: "14400", push_url: nil, push_token: nil, push_host: nil,
|
|
342
|
+
as_job: false, host: nil, token: nil, backend_url: nil, job_model: nil, api_key_ref: nil, **opts)
|
|
331
343
|
setup_terminal(**opts.slice(:color, :colors))
|
|
332
344
|
handle_errors do
|
|
333
345
|
level = resolve_thinking_alias(effort: effort, thinking: thinking, reasoning: reasoning)
|
|
334
346
|
forced_level = resolve_force_thinking_alias(force_effort: force_effort,
|
|
335
347
|
force_thinking: force_thinking, force_reasoning: force_reasoning)
|
|
336
348
|
|
|
349
|
+
if as_job
|
|
350
|
+
Jobs.require_credentials!(host, token)
|
|
351
|
+
raise Space::Core::Error, "--backend-url is required with --as-job" unless backend_url
|
|
352
|
+
raise Space::Core::Error, "--as-job cannot be combined with --push-url/--push-token/--push-host/--detach" \
|
|
353
|
+
if push_url || push_token || push_host || detach
|
|
354
|
+
end
|
|
355
|
+
|
|
337
356
|
render(store.find(space)) do |sp|
|
|
338
357
|
project = ArchitectProject.new(space: sp)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
terminal.say "
|
|
354
|
-
terminal.say "Run log: #{terminal.path(res[:run_log])}"
|
|
355
|
-
terminal.say "Report: #{terminal.path(res[:report])}"
|
|
356
|
-
terminal.say "Dispatched detached — poll #{terminal.path(res[:report])} for completion"
|
|
358
|
+
|
|
359
|
+
if as_job
|
|
360
|
+
kwargs = { host: host, token: token, backend_url: backend_url, max_turns: max_turns.to_i }
|
|
361
|
+
kwargs[:prompt] = prompt if prompt
|
|
362
|
+
kwargs[:model] = model if model
|
|
363
|
+
kwargs[:harness] = harness if harness
|
|
364
|
+
kwargs[:effort] = forced_level || level if forced_level || level
|
|
365
|
+
kwargs[:force] = true if forced_level
|
|
366
|
+
kwargs[:quiet] = true if quiet
|
|
367
|
+
kwargs[:job_model] = job_model if job_model
|
|
368
|
+
kwargs[:api_key_ref] = api_key_ref if api_key_ref
|
|
369
|
+
res = project.dispatch_as_job(iteration, lane, **kwargs)
|
|
370
|
+
terminal.say "Prompt: #{prompt} → #{terminal.path(res[:prompt_copied])}" if res[:prompt_copied]
|
|
371
|
+
terminal.say "Job: #{res[:job_id]}"
|
|
372
|
+
terminal.say "Watch: architect jobs watch #{res[:job_id]} --host #{host} --token #{token}"
|
|
357
373
|
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
358
|
-
elsif res[:timed_out]
|
|
359
|
-
terminal.say "Run log: #{terminal.path(res[:run_log])}"
|
|
360
|
-
terminal.say "Report: #{terminal.path(res[:report])}"
|
|
361
|
-
terminal.say "Builder TIMED OUT after #{timeout}s — process group killed. Re-dispatch (lanes are cheap)."
|
|
362
|
-
CLI.record_outcome(Outcome.new(exit_code: res[:exit_code]))
|
|
363
374
|
else
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
375
|
+
kwargs = { max_turns: max_turns.to_i, detach: detach }
|
|
376
|
+
kwargs[:prompt] = prompt if prompt
|
|
377
|
+
kwargs[:model] = model if model
|
|
378
|
+
kwargs[:harness] = harness if harness
|
|
379
|
+
kwargs[:effort] = forced_level || level if forced_level || level
|
|
380
|
+
kwargs[:force] = true if forced_level
|
|
381
|
+
kwargs[:quiet] = true if quiet
|
|
382
|
+
kwargs[:timeout] = timeout.to_i unless detach
|
|
383
|
+
kwargs[:push_url] = push_url if push_url
|
|
384
|
+
kwargs[:push_token] = push_token if push_token
|
|
385
|
+
kwargs[:push_host] = push_host if push_host
|
|
386
|
+
res = project.dispatch(iteration, lane, **kwargs)
|
|
387
|
+
terminal.say "Prompt: #{prompt} → #{terminal.path(res[:prompt_copied])}" if res[:prompt_copied]
|
|
388
|
+
if detach
|
|
389
|
+
terminal.say "PID: #{res[:pid]}"
|
|
390
|
+
terminal.say "Run log: #{terminal.path(res[:run_log])}"
|
|
391
|
+
terminal.say "Report: #{terminal.path(res[:report])}"
|
|
392
|
+
terminal.say "Dispatched detached — poll #{terminal.path(res[:report])} for completion"
|
|
393
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
394
|
+
elsif res[:timed_out]
|
|
395
|
+
terminal.say "Run log: #{terminal.path(res[:run_log])}"
|
|
396
|
+
terminal.say "Report: #{terminal.path(res[:report])}"
|
|
397
|
+
terminal.say "Builder TIMED OUT after #{timeout}s — process group killed. Re-dispatch (lanes are cheap)."
|
|
398
|
+
CLI.record_outcome(Outcome.new(exit_code: res[:exit_code]))
|
|
399
|
+
else
|
|
400
|
+
terminal.say "Run log: #{terminal.path(res[:run_log])}"
|
|
401
|
+
terminal.say "Report: #{terminal.path(res[:report])}"
|
|
402
|
+
terminal.say "Ingest URL: #{res[:push_url]}" if res[:push_url]
|
|
403
|
+
terminal.say "Builder exited with status #{res[:exit_code]}"
|
|
404
|
+
CLI.record_outcome(Outcome.new(exit_code: res[:exit_code]))
|
|
405
|
+
end
|
|
369
406
|
end
|
|
370
407
|
end
|
|
371
408
|
end
|
|
@@ -821,6 +858,217 @@ module Space::Architect
|
|
|
821
858
|
end
|
|
822
859
|
end
|
|
823
860
|
end
|
|
861
|
+
|
|
862
|
+
module Jobs
|
|
863
|
+
# dry-cli 1.4.1 only enforces `required: true` on argument, not on
|
|
864
|
+
# option (Dry::CLI::Parser#parse_required_params only ever consults
|
|
865
|
+
# command.required_arguments) — a missing --host/--token would
|
|
866
|
+
# otherwise surface as a raw ArgumentError from the **opts keyword
|
|
867
|
+
# splat, uncaught by handle_errors. Every subcommand below validates
|
|
868
|
+
# explicitly so the failure is a clean Space::Core::Error instead.
|
|
869
|
+
def self.require_credentials!(host, token)
|
|
870
|
+
raise Space::Core::Error, "--host is required" unless host
|
|
871
|
+
raise Space::Core::Error, "--token is required" unless token
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
class List < BaseCommand
|
|
875
|
+
desc "List jobs for the authenticated user (owner-scoped, newest-first)"
|
|
876
|
+
option :host, required: true, desc: "Base URL of the space-server"
|
|
877
|
+
option :token, required: true, desc: "Bearer token for authorization"
|
|
878
|
+
|
|
879
|
+
def call(host: nil, token: nil, **opts)
|
|
880
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
881
|
+
handle_errors do
|
|
882
|
+
Jobs.require_credentials!(host, token)
|
|
883
|
+
jobs = JobsClient.new(host, token).list
|
|
884
|
+
if jobs.empty?
|
|
885
|
+
terminal.say "No jobs"
|
|
886
|
+
else
|
|
887
|
+
rows = jobs.map { |j| [j["id"], j["status"], j["run_id"], j["created_at"]] }
|
|
888
|
+
terminal.say terminal.table(%w[ID Status RunID CreatedAt], rows)
|
|
889
|
+
end
|
|
890
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
891
|
+
end
|
|
892
|
+
end
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
class Show < BaseCommand
|
|
896
|
+
desc "Show a job's full JSON"
|
|
897
|
+
argument :id, required: true, desc: "Job id"
|
|
898
|
+
option :host, required: true, desc: "Base URL of the space-server"
|
|
899
|
+
option :token, required: true, desc: "Bearer token for authorization"
|
|
900
|
+
|
|
901
|
+
def call(id:, host: nil, token: nil, **opts)
|
|
902
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
903
|
+
handle_errors do
|
|
904
|
+
Jobs.require_credentials!(host, token)
|
|
905
|
+
job = JobsClient.new(host, token).show(id)
|
|
906
|
+
terminal.say JSON.pretty_generate(job)
|
|
907
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
908
|
+
end
|
|
909
|
+
end
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
class Cancel < BaseCommand
|
|
913
|
+
desc "Cancel a job"
|
|
914
|
+
argument :id, required: true, desc: "Job id"
|
|
915
|
+
option :host, required: true, desc: "Base URL of the space-server"
|
|
916
|
+
option :token, required: true, desc: "Bearer token for authorization"
|
|
917
|
+
|
|
918
|
+
def call(id:, host: nil, token: nil, **opts)
|
|
919
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
920
|
+
handle_errors do
|
|
921
|
+
Jobs.require_credentials!(host, token)
|
|
922
|
+
result = JobsClient.new(host, token).cancel(id)
|
|
923
|
+
terminal.say JSON.pretty_generate(result)
|
|
924
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
925
|
+
end
|
|
926
|
+
end
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
class Watch < BaseCommand
|
|
930
|
+
desc "Resolve a job's run and stream its live events (SSE) until run_complete"
|
|
931
|
+
argument :id, required: true, desc: "Job id"
|
|
932
|
+
option :host, required: true, desc: "Base URL of the space-server"
|
|
933
|
+
option :token, required: true, desc: "Bearer token for authorization"
|
|
934
|
+
|
|
935
|
+
def call(id:, host: nil, token: nil, **opts)
|
|
936
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
937
|
+
handle_errors do
|
|
938
|
+
Jobs.require_credentials!(host, token)
|
|
939
|
+
client = JobsClient.new(host, token)
|
|
940
|
+
run_id = client.wait_for_run_id(id)
|
|
941
|
+
client.stream(run_id) { |data| terminal.say data }
|
|
942
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
943
|
+
end
|
|
944
|
+
end
|
|
945
|
+
end
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
module Sessions
|
|
949
|
+
# See Jobs.require_credentials! above for why this is validated
|
|
950
|
+
# explicitly rather than relying on dry-cli's `required:` option flag.
|
|
951
|
+
def self.require_credentials!(host, token)
|
|
952
|
+
raise Space::Core::Error, "--host is required" unless host
|
|
953
|
+
raise Space::Core::Error, "--token is required" unless token
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
class Sync < BaseCommand
|
|
957
|
+
desc "Scan pi/claude session files and upload new/grown conversations to a space-server"
|
|
958
|
+
option :host, required: true, desc: "Base URL of the space-server"
|
|
959
|
+
option :token, required: true, desc: "Bearer token for authorization (an op:// ref is resolved once via `op read`)"
|
|
960
|
+
option :state_file, default: nil, desc: "Cursor YAML path (default: $XDG_STATE_HOME/space-architect/session-sync.yaml)"
|
|
961
|
+
option :pi_root, default: nil, desc: "Override the pi sessions root (default: ~/.pi/agent/sessions)"
|
|
962
|
+
option :claude_root, default: nil, desc: "Override the claude projects root (default: ~/.claude/projects)"
|
|
963
|
+
option :dry_run, type: :boolean, default: false, desc: "Report what would upload without uploading or recording the cursor"
|
|
964
|
+
|
|
965
|
+
def call(host: nil, token: nil, state_file: nil, pi_root: nil, claude_root: nil, dry_run: false, **opts)
|
|
966
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
967
|
+
handle_errors do
|
|
968
|
+
Sessions.require_credentials!(host, token)
|
|
969
|
+
runner = SessionSync::Runner.new(
|
|
970
|
+
client: ConversationsClient.new(host, token),
|
|
971
|
+
state_path: state_file || SessionSync.default_state_file,
|
|
972
|
+
pi_root: pi_root || SessionSync.default_pi_root,
|
|
973
|
+
claude_root: claude_root || SessionSync.default_claude_root,
|
|
974
|
+
dry_run: dry_run
|
|
975
|
+
)
|
|
976
|
+
results = runner.call
|
|
977
|
+
results.each do |r|
|
|
978
|
+
terminal.say "#{r[:action]}: #{r[:path]}#{r[:action] == :failed ? " (#{r[:status]}: #{r[:errors]})" : ""}"
|
|
979
|
+
end
|
|
980
|
+
failed = results.count { |r| r[:action] == :failed }
|
|
981
|
+
terminal.say "#{results.size} file(s): #{results.size - failed} ok, #{failed} failed"
|
|
982
|
+
CLI.record_outcome(Outcome.new(exit_code: failed.zero? ? 0 : 1))
|
|
983
|
+
end
|
|
984
|
+
end
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
module Agent
|
|
988
|
+
module Helpers
|
|
989
|
+
def plist_path
|
|
990
|
+
File.join(SessionSync.launch_agents_dir, "#{SessionSync::LABEL}.plist")
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
def format_failure(f) = f.is_a?(Hash) ? f.inspect : f.to_s
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
class Install < BaseCommand
|
|
997
|
+
include Helpers
|
|
998
|
+
desc "Install the launchd agent that periodically runs `architect sessions sync`"
|
|
999
|
+
option :host, required: true, desc: "Base URL of the space-server"
|
|
1000
|
+
option :token, required: true, desc: "Bearer token for authorization (an op:// ref is stored as-is in the plist)"
|
|
1001
|
+
option :interval, default: "900", desc: "StartInterval in seconds (default 900)"
|
|
1002
|
+
|
|
1003
|
+
def call(host: nil, token: nil, interval: "900", **opts)
|
|
1004
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
1005
|
+
handle_errors do
|
|
1006
|
+
Sessions.require_credentials!(host, token)
|
|
1007
|
+
pp = plist_path
|
|
1008
|
+
xml = SessionSync::Plist.call(
|
|
1009
|
+
label: SessionSync::LABEL,
|
|
1010
|
+
refresh_interval: interval.to_i,
|
|
1011
|
+
log_dir: SessionSync.log_dir,
|
|
1012
|
+
bin_path: SessionSync::BinPath.detect,
|
|
1013
|
+
host: host,
|
|
1014
|
+
token: token
|
|
1015
|
+
)
|
|
1016
|
+
FileUtils.mkdir_p(File.dirname(pp))
|
|
1017
|
+
File.write(pp, xml)
|
|
1018
|
+
|
|
1019
|
+
result = Space::Src::Launchd::Agent.new(label: SessionSync::LABEL).install(pp)
|
|
1020
|
+
raise Space::Core::Error, "bootstrap failed: #{format_failure(result.failure)}" if result.failure?
|
|
1021
|
+
|
|
1022
|
+
terminal.say "Installed: #{terminal.path(pp)}"
|
|
1023
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
1024
|
+
end
|
|
1025
|
+
end
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
class Uninstall < BaseCommand
|
|
1029
|
+
include Helpers
|
|
1030
|
+
desc "Uninstall the session-sync launchd agent (bootout + remove the plist)"
|
|
1031
|
+
|
|
1032
|
+
def call(**opts)
|
|
1033
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
1034
|
+
handle_errors do
|
|
1035
|
+
pp = plist_path
|
|
1036
|
+
result = Space::Src::Launchd::Agent.new(label: SessionSync::LABEL).uninstall
|
|
1037
|
+
terminal.say "bootout reported: #{format_failure(result.failure)}" if result.failure?
|
|
1038
|
+
|
|
1039
|
+
if File.exist?(pp)
|
|
1040
|
+
File.delete(pp)
|
|
1041
|
+
terminal.say "Removed plist: #{terminal.path(pp)}"
|
|
1042
|
+
else
|
|
1043
|
+
terminal.say "Plist not present: #{terminal.path(pp)}"
|
|
1044
|
+
end
|
|
1045
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
1046
|
+
end
|
|
1047
|
+
end
|
|
1048
|
+
end
|
|
1049
|
+
|
|
1050
|
+
class Status < BaseCommand
|
|
1051
|
+
include Helpers
|
|
1052
|
+
desc "Report the session-sync launchd agent's loaded/running/last-exit state"
|
|
1053
|
+
|
|
1054
|
+
def call(**opts)
|
|
1055
|
+
setup_terminal(**opts.slice(:color, :colors))
|
|
1056
|
+
handle_errors do
|
|
1057
|
+
result = Space::Src::Launchd::Agent.new(label: SessionSync::LABEL).status
|
|
1058
|
+
raise Space::Core::Error, "status failed: #{format_failure(result.failure)}" if result.failure?
|
|
1059
|
+
|
|
1060
|
+
s = result.success
|
|
1061
|
+
terminal.say "label: #{SessionSync::LABEL}"
|
|
1062
|
+
terminal.say "loaded: #{s[:loaded]}"
|
|
1063
|
+
terminal.say "running: #{s[:running]}"
|
|
1064
|
+
terminal.say "pid: #{s[:pid].inspect}"
|
|
1065
|
+
terminal.say "last_exit: #{s[:last_exit].inspect}"
|
|
1066
|
+
CLI.record_outcome(Outcome.new(exit_code: 0))
|
|
1067
|
+
end
|
|
1068
|
+
end
|
|
1069
|
+
end
|
|
1070
|
+
end
|
|
1071
|
+
end
|
|
824
1072
|
end
|
|
825
1073
|
end
|
|
826
1074
|
end
|
|
@@ -854,6 +1102,20 @@ Space::Architect::CLI::Registry.register "worktree" do |wt|
|
|
|
854
1102
|
wt.register "remove", Space::Architect::CLI::Architect::Worktree::Remove
|
|
855
1103
|
wt.register "list", Space::Architect::CLI::Architect::Worktree::List
|
|
856
1104
|
end
|
|
1105
|
+
Space::Architect::CLI::Registry.register "jobs" do |j|
|
|
1106
|
+
j.register "list", Space::Architect::CLI::Architect::Jobs::List
|
|
1107
|
+
j.register "show", Space::Architect::CLI::Architect::Jobs::Show
|
|
1108
|
+
j.register "watch", Space::Architect::CLI::Architect::Jobs::Watch
|
|
1109
|
+
j.register "cancel", Space::Architect::CLI::Architect::Jobs::Cancel
|
|
1110
|
+
end
|
|
1111
|
+
Space::Architect::CLI::Registry.register "sessions" do |s|
|
|
1112
|
+
s.register "sync", Space::Architect::CLI::Architect::Sessions::Sync
|
|
1113
|
+
s.register "agent" do |a|
|
|
1114
|
+
a.register "install", Space::Architect::CLI::Architect::Sessions::Agent::Install
|
|
1115
|
+
a.register "uninstall", Space::Architect::CLI::Architect::Sessions::Agent::Uninstall
|
|
1116
|
+
a.register "status", Space::Architect::CLI::Architect::Sessions::Agent::Status
|
|
1117
|
+
end
|
|
1118
|
+
end
|
|
857
1119
|
Space::Architect::CLI::Registry.register "variant" do |v|
|
|
858
1120
|
v.register "add", Space::Architect::CLI::Architect::Variant::Add
|
|
859
1121
|
v.register "promote", Space::Architect::CLI::Architect::Variant::Promote
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/http/client"
|
|
4
|
+
require "async/http/endpoint"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module Space::Architect
|
|
9
|
+
# HTTP client for the space-server conversations upload API, modeled on
|
|
10
|
+
# JobsClient: Bearer auth, injectable async-http client. Unlike JobsClient,
|
|
11
|
+
# a non-2xx response is returned to the caller (not raised) — SessionSync
|
|
12
|
+
# needs to distinguish created/updated/failed per file without aborting
|
|
13
|
+
# the whole run on one bad upload.
|
|
14
|
+
class ConversationsClient
|
|
15
|
+
def initialize(host, token, client: nil, op_resolver: nil)
|
|
16
|
+
@host = host.chomp("/")
|
|
17
|
+
@token = token
|
|
18
|
+
@client = client
|
|
19
|
+
@op_resolver = op_resolver || method(:op_read)
|
|
20
|
+
@resolved_token = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# POST /conversations — multipart per the frozen wire contract:
|
|
24
|
+
# conversation[source_file] (the file), conversation[session_id].
|
|
25
|
+
# Returns {status:, conversation_id:, action:, errors:}.
|
|
26
|
+
def upload(path:, session_id:)
|
|
27
|
+
Sync do
|
|
28
|
+
with_client do |c|
|
|
29
|
+
boundary = "ArchitectBoundary#{SecureRandom.hex(8)}"
|
|
30
|
+
body = build_multipart(boundary, path, session_id)
|
|
31
|
+
response = c.post("/conversations", headers: headers(boundary), body: body)
|
|
32
|
+
status = response.status
|
|
33
|
+
parsed = begin
|
|
34
|
+
JSON.parse(response.read || "{}")
|
|
35
|
+
rescue JSON::ParserError
|
|
36
|
+
{}
|
|
37
|
+
end
|
|
38
|
+
{status: status, conversation_id: parsed["conversation_id"], action: parsed["action"], errors: parsed["errors"]}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def with_client
|
|
46
|
+
if @client
|
|
47
|
+
yield @client
|
|
48
|
+
else
|
|
49
|
+
Async::HTTP::Client.open(Async::HTTP::Endpoint.parse(@host)) { |c| yield c }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def headers(boundary)
|
|
54
|
+
[
|
|
55
|
+
["authorization", "Bearer #{resolved_token}"],
|
|
56
|
+
["content-type", "multipart/form-data; boundary=#{boundary}"]
|
|
57
|
+
]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Resolved once per client instance (per run), per spec.
|
|
61
|
+
def resolved_token
|
|
62
|
+
@resolved_token ||= @token.start_with?("op://") ? @op_resolver.call(@token) : @token
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def op_read(ref)
|
|
66
|
+
value = IO.popen(["op", "read", ref], &:read)
|
|
67
|
+
raise Space::Core::Error, "op read failed for #{ref}" unless $?.success?
|
|
68
|
+
|
|
69
|
+
value.chomp
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_multipart(boundary, path, session_id)
|
|
73
|
+
crlf = "\r\n"
|
|
74
|
+
filename = File.basename(path)
|
|
75
|
+
file_content = File.binread(path)
|
|
76
|
+
+"--#{boundary}#{crlf}" \
|
|
77
|
+
"Content-Disposition: form-data; name=\"conversation[source_file]\"; filename=\"#{filename}\"#{crlf}" \
|
|
78
|
+
"Content-Type: application/octet-stream#{crlf}" \
|
|
79
|
+
"#{crlf}" \
|
|
80
|
+
"#{file_content}#{crlf}" \
|
|
81
|
+
"--#{boundary}#{crlf}" \
|
|
82
|
+
"Content-Disposition: form-data; name=\"conversation[session_id]\"#{crlf}" \
|
|
83
|
+
"#{crlf}" \
|
|
84
|
+
"#{session_id}#{crlf}" \
|
|
85
|
+
"--#{boundary}--#{crlf}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -188,6 +188,23 @@ module Space::Architect
|
|
|
188
188
|
pid
|
|
189
189
|
end
|
|
190
190
|
|
|
191
|
+
# The builder flag set independent of prompt delivery, --model, and the
|
|
192
|
+
# --output-format/--verbose pair — a sandboxed `dispatch --as-job` executor
|
|
193
|
+
# supplies -p/the prompt/--model/--output-format itself (see the space-server's
|
|
194
|
+
# SandboxArgv#build), so JobsClient spec composition reuses this verbatim
|
|
195
|
+
# instead of duplicating a second flag list (DRY).
|
|
196
|
+
def builder_args
|
|
197
|
+
args = [
|
|
198
|
+
"--permission-mode", "acceptEdits",
|
|
199
|
+
"--allowedTools", @allowed_tools,
|
|
200
|
+
"--include-partial-messages",
|
|
201
|
+
"--max-turns", @max_turns.to_s
|
|
202
|
+
]
|
|
203
|
+
args += ["--disallowedTools", @disallowed_tools] unless @disallowed_tools.to_s.empty?
|
|
204
|
+
args += ["--effort", @effort] if @effort
|
|
205
|
+
args
|
|
206
|
+
end
|
|
207
|
+
|
|
191
208
|
private
|
|
192
209
|
|
|
193
210
|
# Read the run log's stream-json init event and print exactly one bounded liveness
|
|
@@ -227,19 +244,7 @@ module Space::Architect
|
|
|
227
244
|
end
|
|
228
245
|
|
|
229
246
|
def argv
|
|
230
|
-
|
|
231
|
-
@bin, "-p",
|
|
232
|
-
"--model", @model,
|
|
233
|
-
"--permission-mode", "acceptEdits",
|
|
234
|
-
"--allowedTools", @allowed_tools,
|
|
235
|
-
"--output-format", "stream-json",
|
|
236
|
-
"--verbose",
|
|
237
|
-
"--include-partial-messages",
|
|
238
|
-
"--max-turns", @max_turns.to_s
|
|
239
|
-
]
|
|
240
|
-
args += ["--disallowedTools", @disallowed_tools] unless @disallowed_tools.to_s.empty?
|
|
241
|
-
args += ["--effort", @effort] if @effort
|
|
242
|
-
args
|
|
247
|
+
[@bin, "-p", "--model", @model, "--output-format", "stream-json", "--verbose"] + builder_args
|
|
243
248
|
end
|
|
244
249
|
|
|
245
250
|
def start_tee(r, log, push_url:, push_token:, push_client:, err: $stderr)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/http/client"
|
|
4
|
+
require "async/http/endpoint"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Space::Architect
|
|
8
|
+
# HTTP client for the space-server jobs API, modeled on RunCreator: Bearer
|
|
9
|
+
# auth, injectable async-http client, Space::Core::Error on any failure.
|
|
10
|
+
class JobsClient
|
|
11
|
+
# Poll bound for #wait_for_run_id: 2s * 30 attempts = 60s. Both are
|
|
12
|
+
# injectable kwargs so tests need not sleep real seconds.
|
|
13
|
+
POLL_INTERVAL_SECONDS = 2
|
|
14
|
+
POLL_MAX_ATTEMPTS = 30
|
|
15
|
+
|
|
16
|
+
def initialize(host, token, client: nil)
|
|
17
|
+
@host = host.chomp("/")
|
|
18
|
+
@token = token
|
|
19
|
+
@client = client
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# GET /jobs — returns the "jobs" array (owner-scoped, newest-first).
|
|
23
|
+
def list
|
|
24
|
+
get_json("/jobs")["jobs"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# GET /jobs/:id — returns the job JSON.
|
|
28
|
+
def show(id)
|
|
29
|
+
get_json("/jobs/#{id}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# POST /jobs — submits a job spec (harness/prompt/environment) and returns the
|
|
33
|
+
# created job's integer id. Raises Space::Core::Error on any failure, modeled on
|
|
34
|
+
# RunCreator#create (same 20x-status + integer-id checks).
|
|
35
|
+
def create(spec)
|
|
36
|
+
Sync do
|
|
37
|
+
with_client do |c|
|
|
38
|
+
response = c.post("/jobs", headers: headers, body: JSON.generate(spec))
|
|
39
|
+
parsed = parse_json(response, "POST /jobs", expected_status: 201)
|
|
40
|
+
id = parsed["id"]
|
|
41
|
+
raise Space::Core::Error, "POST /jobs: missing or non-integer id in response: #{parsed.inspect[0, 200]}" \
|
|
42
|
+
unless id.is_a?(Integer)
|
|
43
|
+
|
|
44
|
+
id
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# POST /jobs/:id/cancel — returns {"id"=>.., "status"=>"canceled"}.
|
|
50
|
+
def cancel(id)
|
|
51
|
+
Sync do
|
|
52
|
+
with_client do |c|
|
|
53
|
+
response = c.post("/jobs/#{id}/cancel", headers: headers, body: nil)
|
|
54
|
+
parse_json(response, "POST /jobs/#{id}/cancel")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# GET /runs/:id/stream — yields each SSE event's data: payload to the
|
|
60
|
+
# block as it arrives. Returns when the stream reports run_complete or
|
|
61
|
+
# closes cleanly. Raises Space::Core::Error on a non-200 response.
|
|
62
|
+
def stream(run_id, &block)
|
|
63
|
+
Sync do
|
|
64
|
+
with_client do |c|
|
|
65
|
+
response = c.get("/runs/#{run_id}/stream", headers: headers)
|
|
66
|
+
unless response.status == 200
|
|
67
|
+
body = response.read || ""
|
|
68
|
+
raise Space::Core::Error, "GET /runs/#{run_id}/stream failed (#{response.status}): #{body[0, 200]}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
read_sse(response, &block)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Polls GET /jobs/:id until run_id is present, waiting `interval` seconds
|
|
77
|
+
# between attempts, up to `attempts` tries. Raises Space::Core::Error if
|
|
78
|
+
# the bound is exceeded.
|
|
79
|
+
def wait_for_run_id(id, interval: POLL_INTERVAL_SECONDS, attempts: POLL_MAX_ATTEMPTS)
|
|
80
|
+
attempts.times do |i|
|
|
81
|
+
run_id = show(id)["run_id"]
|
|
82
|
+
return run_id if run_id
|
|
83
|
+
sleep interval unless i == attempts - 1
|
|
84
|
+
end
|
|
85
|
+
raise Space::Core::Error, "job #{id}: no run_id after #{attempts} attempts (#{attempts * interval}s)"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def get_json(path)
|
|
91
|
+
Sync do
|
|
92
|
+
with_client do |c|
|
|
93
|
+
response = c.get(path, headers: headers)
|
|
94
|
+
parse_json(response, "GET #{path}")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def with_client
|
|
100
|
+
if @client
|
|
101
|
+
yield @client
|
|
102
|
+
else
|
|
103
|
+
Async::HTTP::Client.open(Async::HTTP::Endpoint.parse(@host)) { |c| yield c }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def headers
|
|
108
|
+
[
|
|
109
|
+
["authorization", "Bearer #{@token}"],
|
|
110
|
+
["content-type", "application/json"]
|
|
111
|
+
]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def parse_json(response, what, expected_status: 200)
|
|
115
|
+
status = response.status
|
|
116
|
+
body = response.read || ""
|
|
117
|
+
raise Space::Core::Error, "#{what} failed (#{status}): #{body[0, 200]}" unless status == expected_status
|
|
118
|
+
|
|
119
|
+
JSON.parse(body)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Buffers chunks and splits on the SSE event terminator ("\n\n"). Each
|
|
123
|
+
# event's data: line(s) are joined and yielded; an event whose data JSON
|
|
124
|
+
# carries type == "run_complete" ends the stream.
|
|
125
|
+
def read_sse(response)
|
|
126
|
+
buffer = +""
|
|
127
|
+
response.each do |chunk|
|
|
128
|
+
buffer << chunk
|
|
129
|
+
while (boundary = buffer.index("\n\n"))
|
|
130
|
+
event = buffer.slice!(0..boundary + 1)
|
|
131
|
+
data = event.each_line.select { |line| line.start_with?("data:") }
|
|
132
|
+
.map { |line| line.sub(/\Adata:\s?/, "").chomp }
|
|
133
|
+
.join("\n")
|
|
134
|
+
next if data.empty?
|
|
135
|
+
|
|
136
|
+
yield data
|
|
137
|
+
parsed = begin
|
|
138
|
+
JSON.parse(data)
|
|
139
|
+
rescue JSON::ParserError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
return if parsed.is_a?(Hash) && parsed["type"] == "run_complete"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Space::Architect
|
|
6
|
+
module SessionSync
|
|
7
|
+
# Resolves the absolute path to the `architect` executable the plist
|
|
8
|
+
# should invoke, imitating Space::Src::CLI::Daemon::Helpers::Resolve's
|
|
9
|
+
# detect_bin_path precedence: an env override (tests), the dev checkout's
|
|
10
|
+
# exe/architect, `which architect`, then the installed gem's bin.
|
|
11
|
+
module BinPath
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def detect(env: ENV)
|
|
15
|
+
override = env["SPACE_ARCHITECT_BIN_PATH"]
|
|
16
|
+
return override if override && !override.empty?
|
|
17
|
+
|
|
18
|
+
dev = File.expand_path("../../../exe/architect", __dir__)
|
|
19
|
+
return dev if File.exist?(dev)
|
|
20
|
+
|
|
21
|
+
out, _err, status = Open3.capture3("which", "architect")
|
|
22
|
+
return out.strip if status.success? && !out.strip.empty?
|
|
23
|
+
|
|
24
|
+
Gem.bin_path("space-architect", "architect")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Space::Architect
|
|
7
|
+
module SessionSync
|
|
8
|
+
# Machine-managed cursor at $XDG_STATE_HOME/space-architect/session-sync.yaml
|
|
9
|
+
# (imitates Space::Src::State::Store's load/write/emit shape). Keyed by
|
|
10
|
+
# absolute source path; each entry records the size + mtime observed the
|
|
11
|
+
# last time that file was uploaded.
|
|
12
|
+
class Cursor
|
|
13
|
+
Entry = Data.define(:size, :mtime) do
|
|
14
|
+
def initialize(size:, mtime:) = super
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.load(path)
|
|
18
|
+
raw = read_yaml(path)
|
|
19
|
+
raw.each_with_object({}) do |(k, v), acc|
|
|
20
|
+
acc[k] = Entry.new(size: v["size"], mtime: v["mtime"])
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.write(path, entries)
|
|
25
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
26
|
+
payload = entries.each_with_object({}) { |(k, v), acc| acc[k] = {"size" => v.size, "mtime" => v.mtime} }
|
|
27
|
+
tmp = "#{path}.tmp.#{Process.pid}"
|
|
28
|
+
begin
|
|
29
|
+
File.write(tmp, YAML.dump(payload, line_width: -1))
|
|
30
|
+
File.rename(tmp, path)
|
|
31
|
+
ensure
|
|
32
|
+
File.delete(tmp) if File.exist?(tmp)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.read_yaml(path)
|
|
37
|
+
return {} unless File.exist?(path)
|
|
38
|
+
|
|
39
|
+
YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Architect
|
|
4
|
+
module SessionSync
|
|
5
|
+
# Hand-rolled launchd plist emitter for the session-sync agent, imitating
|
|
6
|
+
# Space::Src::Launchd::Plist's shape. Unlike that emitter, this one does
|
|
7
|
+
# NOT wrap the invocation in mise/ruby toolchain resolution: the
|
|
8
|
+
# `architect` executable is a rubygems binstub (or the dev exe/architect
|
|
9
|
+
# script, which sets up Bundler itself) and needs no toolchain pinning to
|
|
10
|
+
# run non-interactively, so ProgramArguments invokes it directly.
|
|
11
|
+
class Plist
|
|
12
|
+
HEADER = <<~XML
|
|
13
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
14
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
15
|
+
<plist version="1.0">
|
|
16
|
+
<dict>
|
|
17
|
+
XML
|
|
18
|
+
FOOTER = "</dict>\n</plist>\n"
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# @param label [String] The job label (reverse-DNS; on-disk plist basename).
|
|
22
|
+
# @param refresh_interval [Integer] StartInterval in seconds (must be > 0).
|
|
23
|
+
# @param log_dir [String] Absolute directory for stdout/stderr logs.
|
|
24
|
+
# @param bin_path [String] Absolute path to the architect bin script.
|
|
25
|
+
# @param host [String] --host value passed to `sessions sync`.
|
|
26
|
+
# @param token [String] --token value passed to `sessions sync` (an op:// ref
|
|
27
|
+
# is passed through verbatim — never resolved here).
|
|
28
|
+
# @return [String] the full plist XML.
|
|
29
|
+
def call(label:, refresh_interval:, log_dir:, bin_path:, host:, token:)
|
|
30
|
+
raise ArgumentError, "label is required" if label.to_s.empty?
|
|
31
|
+
raise ArgumentError, "refresh_interval must be > 0" unless refresh_interval.is_a?(Integer) && refresh_interval > 0
|
|
32
|
+
%w[log_dir bin_path].each do |k|
|
|
33
|
+
v = binding.local_variable_get(k)
|
|
34
|
+
raise ArgumentError, "#{k} must be absolute (got #{v.inspect})" unless v.is_a?(String) && File.absolute_path?(v)
|
|
35
|
+
end
|
|
36
|
+
raise ArgumentError, "host is required" if host.to_s.empty?
|
|
37
|
+
raise ArgumentError, "token is required" if token.to_s.empty?
|
|
38
|
+
|
|
39
|
+
out_log = File.join(log_dir, "#{label}.out.log")
|
|
40
|
+
err_log = File.join(log_dir, "#{label}.err.log")
|
|
41
|
+
|
|
42
|
+
body = +""
|
|
43
|
+
body << key("Label") << string(label) << "\n"
|
|
44
|
+
body << key("ProgramArguments") << "\n" << array([
|
|
45
|
+
bin_path,
|
|
46
|
+
"sessions",
|
|
47
|
+
"sync",
|
|
48
|
+
"--host", host,
|
|
49
|
+
"--token", token
|
|
50
|
+
])
|
|
51
|
+
body << key("StartInterval") << integer(refresh_interval) << "\n"
|
|
52
|
+
body << key("RunAtLoad") << boolean(true) << "\n"
|
|
53
|
+
body << key("ProcessType") << string("Background") << "\n"
|
|
54
|
+
body << key("StandardOutPath") << string(out_log) << "\n"
|
|
55
|
+
body << key("StandardErrorPath") << string(err_log) << "\n"
|
|
56
|
+
|
|
57
|
+
HEADER + body + FOOTER
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def key(name)
|
|
63
|
+
" <key>#{escape(name)}</key>\n"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def escape(s)
|
|
67
|
+
s.to_s
|
|
68
|
+
.gsub("&", "&")
|
|
69
|
+
.gsub("<", "<")
|
|
70
|
+
.gsub(">", ">")
|
|
71
|
+
.gsub("\"", """)
|
|
72
|
+
.gsub("'", "'")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def string(s)
|
|
76
|
+
" <string>#{escape(s)}</string>"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def integer(i)
|
|
80
|
+
" <integer>#{Integer(i)}</integer>"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def boolean(b)
|
|
84
|
+
" <#{b ? "true" : "false"}/>"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def array(items)
|
|
88
|
+
out = +" <array>\n"
|
|
89
|
+
items.each { |arg| out << " <string>#{escape(arg)}</string>\n" }
|
|
90
|
+
out << " </array>"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cursor"
|
|
4
|
+
require_relative "session_id"
|
|
5
|
+
|
|
6
|
+
module Space::Architect
|
|
7
|
+
module SessionSync
|
|
8
|
+
# One sync pass: scans the pi + claude session roots, decides
|
|
9
|
+
# upload/skip per the frozen cursor rules, uploads via the injected
|
|
10
|
+
# client, and persists the updated cursor (unless dry_run).
|
|
11
|
+
class Runner
|
|
12
|
+
RECENT_MTIME_SECONDS = 60
|
|
13
|
+
|
|
14
|
+
def initialize(client:, state_path:, pi_root:, claude_root:, now: -> { Time.now }, dry_run: false)
|
|
15
|
+
@client = client
|
|
16
|
+
@state_path = state_path
|
|
17
|
+
@pi_root = pi_root
|
|
18
|
+
@claude_root = claude_root
|
|
19
|
+
@now = now
|
|
20
|
+
@dry_run = dry_run
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
cursor = Cursor.load(@state_path)
|
|
25
|
+
results = files.map { |path, session_id| process(path, session_id, cursor) }
|
|
26
|
+
Cursor.write(@state_path, cursor) unless @dry_run
|
|
27
|
+
results
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def process(path, session_id, cursor)
|
|
33
|
+
stat = File.stat(path)
|
|
34
|
+
recorded = cursor[path]
|
|
35
|
+
|
|
36
|
+
return {path: path, session_id: session_id, action: :skipped, reason: "recent mtime"} if recent_mtime?(stat)
|
|
37
|
+
return {path: path, session_id: session_id, action: :skipped, reason: "unchanged"} if recorded && recorded.size >= stat.size
|
|
38
|
+
|
|
39
|
+
return {path: path, session_id: session_id, action: :would_upload} if @dry_run
|
|
40
|
+
|
|
41
|
+
response = @client.upload(path: path, session_id: session_id)
|
|
42
|
+
if [200, 201].include?(response[:status])
|
|
43
|
+
cursor[path] = Cursor::Entry.new(size: stat.size, mtime: stat.mtime.to_i)
|
|
44
|
+
{path: path, session_id: session_id, action: response[:status] == 201 ? :uploaded : :updated,
|
|
45
|
+
conversation_id: response[:conversation_id]}
|
|
46
|
+
else
|
|
47
|
+
{path: path, session_id: session_id, action: :failed, status: response[:status], errors: response[:errors]}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def recent_mtime?(stat)
|
|
52
|
+
(@now.call - stat.mtime) < RECENT_MTIME_SECONDS
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def files
|
|
56
|
+
pi = Dir.glob(File.join(@pi_root, "**", "*.jsonl")).map { |p| [p, SessionId.for_pi(p)] }
|
|
57
|
+
claude = Dir.glob(File.join(@claude_root, "**", "*.jsonl")).map { |p| [p, SessionId.for_claude(p)] }
|
|
58
|
+
pi + claude
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Space::Architect
|
|
4
|
+
module SessionSync
|
|
5
|
+
# Frozen session-id derivation rules for the two store shapes:
|
|
6
|
+
# pi: <mangled-cwd>/<timestamp>_<sessionId>.jsonl -> part after the last "_"
|
|
7
|
+
# claude: <mangled-cwd>/<sessionId>.jsonl -> basename minus ".jsonl"
|
|
8
|
+
module SessionId
|
|
9
|
+
def self.for_pi(path)
|
|
10
|
+
base = File.basename(path, ".jsonl")
|
|
11
|
+
idx = base.rindex("_")
|
|
12
|
+
idx ? base[(idx + 1)..] : base
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.for_claude(path)
|
|
16
|
+
File.basename(path, ".jsonl")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "session_sync/session_id"
|
|
4
|
+
require_relative "session_sync/cursor"
|
|
5
|
+
require_relative "session_sync/runner"
|
|
6
|
+
require_relative "session_sync/plist"
|
|
7
|
+
require_relative "session_sync/bin_path"
|
|
8
|
+
|
|
9
|
+
module Space::Architect
|
|
10
|
+
# Session-sync rail's laptop half: scans ~/.pi/agent/sessions/**/*.jsonl and
|
|
11
|
+
# ~/.claude/projects/**/*.jsonl for conversation files, uploads new/grown
|
|
12
|
+
# ones to a space-server via ConversationsClient, and installs a per-user
|
|
13
|
+
# launchd agent that runs the sync on an interval. Opencode (sqlite) is out
|
|
14
|
+
# of scope for this rail.
|
|
15
|
+
module SessionSync
|
|
16
|
+
LABEL = "io.github.jetpks.space-architect.session-sync"
|
|
17
|
+
APP_NAME = "space-architect"
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def state_dir(env: ENV)
|
|
22
|
+
File.join(Space::Core::XDG.state_home(env: env).to_s, APP_NAME)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def default_state_file(env: ENV)
|
|
26
|
+
File.join(state_dir(env: env), "session-sync.yaml")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def default_pi_root(env: ENV)
|
|
30
|
+
File.join(Space::Core::XDG.home(env: env), ".pi", "agent", "sessions")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def default_claude_root(env: ENV)
|
|
34
|
+
File.join(Space::Core::XDG.home(env: env), ".claude", "projects")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def log_dir(env: ENV)
|
|
38
|
+
File.join(state_dir(env: env), "logs")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def launch_agents_dir(env: ENV)
|
|
42
|
+
File.join(Space::Core::XDG.home(env: env), "Library", "LaunchAgents")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/space_architect.rb
CHANGED
|
@@ -12,4 +12,6 @@ require_relative "space_architect/architect_project"
|
|
|
12
12
|
require_relative "space_architect/skill_installer"
|
|
13
13
|
require_relative "space_architect/bug_report"
|
|
14
14
|
require_relative "space_architect/research"
|
|
15
|
+
require_relative "space_architect/conversations_client"
|
|
16
|
+
require_relative "space_architect/session_sync"
|
|
15
17
|
require_relative "space_architect/cli"
|
data/lib/space_core/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: space-architect
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.
|
|
4
|
+
version: 5.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eric Jacobs
|
|
@@ -260,10 +260,12 @@ files:
|
|
|
260
260
|
- lib/space_architect/cli/research.rb
|
|
261
261
|
- lib/space_architect/cli/space.rb
|
|
262
262
|
- lib/space_architect/cli/src.rb
|
|
263
|
+
- lib/space_architect/conversations_client.rb
|
|
263
264
|
- lib/space_architect/dispatcher.rb
|
|
264
265
|
- lib/space_architect/gate_evaluator.rb
|
|
265
266
|
- lib/space_architect/gate_lint.rb
|
|
266
267
|
- lib/space_architect/harness.rb
|
|
268
|
+
- lib/space_architect/jobs_client.rb
|
|
267
269
|
- lib/space_architect/research.rb
|
|
268
270
|
- lib/space_architect/research/mux.rb
|
|
269
271
|
- lib/space_architect/research/registry.rb
|
|
@@ -271,6 +273,12 @@ files:
|
|
|
271
273
|
- lib/space_architect/research/run.rb
|
|
272
274
|
- lib/space_architect/research/supervisor.rb
|
|
273
275
|
- lib/space_architect/run_creator.rb
|
|
276
|
+
- lib/space_architect/session_sync.rb
|
|
277
|
+
- lib/space_architect/session_sync/bin_path.rb
|
|
278
|
+
- lib/space_architect/session_sync/cursor.rb
|
|
279
|
+
- lib/space_architect/session_sync/plist.rb
|
|
280
|
+
- lib/space_architect/session_sync/runner.rb
|
|
281
|
+
- lib/space_architect/session_sync/session_id.rb
|
|
274
282
|
- lib/space_architect/skill_installer.rb
|
|
275
283
|
- lib/space_architect/templates/architect.md.erb
|
|
276
284
|
- lib/space_architect/templates/brief.md.erb
|