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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 93b3a0ea0bc130ae742448df96c9a1e884ad3ef5b75963f613d81db5029332ce
4
- data.tar.gz: 9d09eecfcc005bde3a4820b1b8de40aa85dd0ce90237545001bed505779b2a0b
3
+ metadata.gz: de2f5d7d6f80acc7b6a7bd16c9ec3dbaf23ef9c2d425c8b1e532f5c24822533a
4
+ data.tar.gz: b39f07d1d24b1288d54e37a08624322ad0fc4f5a664459d6bdcbe12acf4d0438
5
5
  SHA512:
6
- metadata.gz: a5e4bd5be523832721290f02ae4c3e3b790c1fe0b10cc0a70135c65675c5c162fcf201cc755914fd0fba77675562799977f05027359ead2892030c9013fd4c5c
7
- data.tar.gz: '0961b982476aeacfa3be31fc6e3b5f8345ddf568ba2d76932099222a79628edb17f9a6c6ce48b57907793e61d8acb84ba80b16e0a9f166033a100c423855126f'
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
- model, suffix_level = Harness.parse_model_suffix(model)
845
- resolved_harness, resolved_model = resolve_harness_model(harness, model,
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
- if prompt
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, **opts)
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
- kwargs = { max_turns: max_turns.to_i, detach: detach }
340
- kwargs[:prompt] = prompt if prompt
341
- kwargs[:model] = model if model
342
- kwargs[:harness] = harness if harness
343
- kwargs[:effort] = forced_level || level if forced_level || level
344
- kwargs[:force] = true if forced_level
345
- kwargs[:quiet] = true if quiet
346
- kwargs[:timeout] = timeout.to_i unless detach
347
- kwargs[:push_url] = push_url if push_url
348
- kwargs[:push_token] = push_token if push_token
349
- kwargs[:push_host] = push_host if push_host
350
- res = project.dispatch(iteration, lane, **kwargs)
351
- terminal.say "Prompt: #{prompt} → #{terminal.path(res[:prompt_copied])}" if res[:prompt_copied]
352
- if detach
353
- terminal.say "PID: #{res[:pid]}"
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
- terminal.say "Run log: #{terminal.path(res[:run_log])}"
365
- terminal.say "Report: #{terminal.path(res[:report])}"
366
- terminal.say "Ingest URL: #{res[:push_url]}" if res[:push_url]
367
- terminal.say "Builder exited with status #{res[:exit_code]}"
368
- CLI.record_outcome(Outcome.new(exit_code: res[:exit_code]))
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
- args = [
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("&", "&amp;")
69
+ .gsub("<", "&lt;")
70
+ .gsub(">", "&gt;")
71
+ .gsub("\"", "&quot;")
72
+ .gsub("'", "&apos;")
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
@@ -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"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Space
4
4
  module Core
5
- VERSION = "5.2.1"
5
+ VERSION = "5.3.0"
6
6
  end
7
7
  end
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.2.1
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